diff --git a/src/app/page.tsx b/src/app/page.tsx
index cb75679..09cd482 100755
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import { useGameStore, useGameLoop, fmt, fmtDec, getFloorElement, computeMaxMana, computeRegen, computeClickMana, calcDamage, getMeditationBonus, getIncursionStrength, canAffordSpellCost } from '@/lib/game/store';
-import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF, PRESTIGE_DEF, MAX_DAY, INCURSION_START_DAY, MANA_PER_ELEMENT, SKILL_CATEGORIES, getStudySpeedMultiplier, getStudyCostMultiplier, ELEMENT_OPPOSITES, HOURS_PER_TICK, TICK_MS } from '@/lib/game/constants';
+import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF, PRESTIGE_DEF, SKILL_CATEGORIES, getStudySpeedMultiplier, getStudyCostMultiplier, ELEMENT_OPPOSITES, HOURS_PER_TICK, TICK_MS } from '@/lib/game/constants';
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
import { LOOT_DROPS } from '@/lib/game/data/loot-drops';
@@ -21,9 +21,9 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Sparkles, Swords, BookOpen, FlaskConical, Trophy, RotateCcw, Pause, Play, Zap, Clock, Target, Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull, Brain, Link, Wind as Force, Droplets, TreeDeciduous, Hourglass, Gem, Star, CircleDot, X, Circle, BarChart3, Award, Package, Heart, ChevronUp, ChevronDown } from 'lucide-react';
import type { GameAction } from '@/lib/game/types';
-import { CraftingTab } from '@/components/game/tabs/CraftingTab';
+import { CraftingTab, SpireTab, SpellsTab, LabTab } from '@/components/game/tabs';
import { FamiliarTab } from '@/components/game/tabs/FamiliarTab';
-import { ComboMeter } from '@/components/game/ComboMeter';
+import { ComboMeter, ActionButtons, CalendarDisplay, CraftingProgress, StudyProgress, ManaDisplay, TimeDisplay } from '@/components/game';
import { LootInventoryDisplay } from '@/components/game/LootInventory';
import { AchievementsDisplay } from '@/components/game/AchievementsDisplay';
import { ACHIEVEMENTS } from '@/lib/game/data/achievements';
@@ -237,279 +237,6 @@ export default function ManaLoopGame() {
// Format time (use shared utility)
const formatTime = formatHour;
- // Calendar rendering
- const renderCalendar = () => {
- const days: React.ReactElement[] = [];
- for (let d = 1; d <= MAX_DAY; d++) {
- let dayClass = 'w-7 h-7 rounded text-xs flex items-center justify-center font-mono border transition-all ';
-
- if (d < store.day) {
- dayClass += 'bg-blue-900/30 border-blue-800/50 text-blue-400';
- } else if (d === store.day) {
- dayClass += 'bg-blue-600/40 border-blue-500 text-blue-300 shadow-lg shadow-blue-500/30';
- } else {
- dayClass += 'bg-gray-800/30 border-gray-700/50 text-gray-500';
- }
-
- if (d >= INCURSION_START_DAY) {
- dayClass += ' border-red-600/50';
- }
-
- days.push(
-
-
-
- {d}
-
-
-
- Day {d}
- {d >= INCURSION_START_DAY && Incursion Active
}
-
-
- );
- }
- return days;
- };
-
- // Action buttons
- const renderActionButtons = () => {
- const actions: { id: GameAction; label: string; icon: typeof Swords; disabled?: boolean }[] = [
- { id: 'meditate', label: 'Meditate', icon: Sparkles },
- { id: 'climb', label: 'Climb', icon: Swords },
- { id: 'study', label: 'Study', icon: BookOpen },
- { id: 'convert', label: 'Convert', icon: FlaskConical },
- ];
-
- // Add crafting actions if player has progress
- const hasDesignProgress = store.designProgress !== null;
- const hasPrepProgress = store.preparationProgress !== null;
- const hasAppProgress = store.applicationProgress !== null;
-
- return (
-
-
- {actions.map(({ id, label, icon: Icon }) => (
- store.setAction(id)}
- >
-
- {label}
-
- ))}
-
-
- {/* Crafting actions row - shown when there's active crafting progress */}
- {(hasDesignProgress || hasPrepProgress || hasAppProgress) && (
-
- hasDesignProgress && store.setAction('design')}
- >
-
- Design
-
- hasPrepProgress && store.setAction('prepare')}
- >
-
- Prepare
-
- hasAppProgress && store.setAction('enchant')}
- >
-
- Enchant
-
-
- )}
-
- );
- };
-
- // Render crafting progress
- const renderCraftingProgress = () => {
- const progressSections: React.ReactNode[] = [];
-
- // Design progress
- if (store.designProgress) {
- const progressPct = Math.min(100, (store.designProgress.progress / store.designProgress.required) * 100);
- progressSections.push(
-
-
-
-
-
- Designing Enchantment
-
-
-
store.cancelDesign?.()}
- >
-
-
-
-
-
- {formatStudyTime(store.designProgress.progress)} / {formatStudyTime(store.designProgress.required)}
- Design Time
-
-
- );
- }
-
- // Preparation progress
- if (store.preparationProgress) {
- const progressPct = Math.min(100, (store.preparationProgress.progress / store.preparationProgress.required) * 100);
- const instance = store.equipmentInstances[store.preparationProgress.equipmentInstanceId];
- progressSections.push(
-
-
-
-
-
- Preparing {instance?.name || 'Equipment'}
-
-
-
store.cancelPreparation?.()}
- >
-
-
-
-
-
- {formatStudyTime(store.preparationProgress.progress)} / {formatStudyTime(store.preparationProgress.required)}
- Mana spent: {fmt(store.preparationProgress.manaCostPaid)}
-
-
- );
- }
-
- // Application progress
- if (store.applicationProgress) {
- const progressPct = Math.min(100, (store.applicationProgress.progress / store.applicationProgress.required) * 100);
- const instance = store.equipmentInstances[store.applicationProgress.equipmentInstanceId];
- const design = store.enchantmentDesigns.find(d => d.id === store.applicationProgress?.designId);
- progressSections.push(
-
-
-
-
-
- Enchanting {instance?.name || 'Equipment'}
-
-
-
- {store.applicationProgress.paused ? (
-
store.resumeApplication?.()}
- >
-
-
- ) : (
-
store.pauseApplication?.()}
- >
-
-
- )}
-
store.cancelApplication?.()}
- >
-
-
-
-
-
-
- {formatStudyTime(store.applicationProgress.progress)} / {formatStudyTime(store.applicationProgress.required)}
- Mana/hr: {fmt(store.applicationProgress.manaPerHour)}
-
- {design && (
-
- Applying: {design.name}
-
- )}
-
- );
- }
-
- return progressSections.length > 0 ? (
-
- {progressSections}
-
- ) : null;
- };
-
- // Render current study progress
- const renderStudyProgress = () => {
- if (!store.currentStudyTarget) return null;
-
- const target = store.currentStudyTarget;
- const progressPct = Math.min(100, (target.progress / target.required) * 100);
- const isSkill = target.type === 'skill';
- const def = isSkill ? SKILLS_DEF[target.id] : SPELLS_DEF[target.id];
- const currentLevel = isSkill ? (store.skills[target.id] || 0) : 0;
-
- return (
-
-
-
-
-
- {def?.name}
- {isSkill && ` Lv.${currentLevel + 1}`}
-
-
-
store.cancelStudy()}
- >
-
-
-
-
-
- {formatStudyTime(target.progress)} / {formatStudyTime(target.required)}
- {studySpeedMult.toFixed(1)}x speed
-
-
- );
- };
-
// Check if spell can be cast
const canCastSpell = (spellId: string): boolean => {
const spell = SPELLS_DEF[spellId];
@@ -517,610 +244,8 @@ export default function ManaLoopGame() {
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
};
- // Spire Tab
- const renderSpireTab = () => (
-
- {/* Current Floor Card */}
-
-
- Current Floor
-
-
-
-
- {store.currentFloor}
-
- / 100
-
- {floorElemDef?.sym} {floorElemDef?.name}
-
- {isGuardianFloor && (
- GUARDIAN
- )}
-
-
- {isGuardianFloor && currentGuardian && (
-
- ⚔️ {currentGuardian.name}
-
- )}
-
- {/* HP Bar */}
-
-
-
- {fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP
- DPS: {store.currentAction === 'climb' && activeEquipmentSpells.length > 0 ? fmtDec(totalDPS) : '—'}
-
-
-
-
-
- {/* Floor Navigation Controls */}
-
-
-
Auto-Direction
-
- store.setClimbDirection?.('up')}
- >
-
- Up
-
- store.setClimbDirection?.('down')}
- >
-
- Down
-
-
-
-
-
- store.changeFloor?.('down')}
- >
-
- Descend
-
- = 100}
- onClick={() => store.changeFloor?.('up')}
- >
-
- Ascend
-
-
-
- {store.clearedFloors?.[store.currentFloor] && (
-
-
- Floor will respawn when you leave and return
-
- )}
-
-
-
-
-
- Best: Floor {store.maxFloorReached} •
- Pacts: {store.signedPacts.length}
-
-
-
- {/* Active Spells Card - Shows all spells from equipped weapons */}
-
-
-
- Active Spells ({activeEquipmentSpells.length})
-
-
-
- {activeEquipmentSpells.length > 0 ? (
-
- {activeEquipmentSpells.map(({ spellId, equipmentId }) => {
- const spellDef = SPELLS_DEF[spellId];
- if (!spellDef) return null;
-
- const spellState = store.equipmentSpellStates?.find(
- s => s.spellId === spellId && s.sourceEquipment === equipmentId
- );
- const progress = spellState?.castProgress || 0;
- const canCast = canAffordSpellCost(spellDef.cost, store.rawMana, store.elements);
-
- return (
-
-
-
- {spellDef.name}
- {spellDef.tier === 0 && Basic }
- {spellDef.tier >= 4 && Legendary }
-
-
- {canCast ? '✓' : '✗'}
-
-
-
- ⚔️ {fmt(calcDamage(store, spellId, floorElem))} dmg •
-
- {' '}{formatSpellCost(spellDef.cost)}
-
- {' '}• ⚡ {(spellDef.castSpeed || 1).toFixed(1)}/hr
-
-
- {/* Cast progress bar when climbing */}
- {store.currentAction === 'climb' && (
-
-
- Cast
- {(progress * 100).toFixed(0)}%
-
-
-
- )}
-
- {spellDef.effects && spellDef.effects.length > 0 && (
-
- {spellDef.effects.map((eff, i) => (
-
- {eff.type === 'lifesteal' && `🩸 ${Math.round(eff.value * 100)}%`}
- {eff.type === 'burn' && `🔥 Burn`}
- {eff.type === 'freeze' && `❄️ Freeze`}
-
- ))}
-
- )}
-
- );
- })}
-
- ) : (
- No spells on equipped weapons. Enchant a staff with spell effects.
- )}
-
- {incursionStrength > 0 && (
-
-
LABYRINTH INCURSION
-
- -{Math.round(incursionStrength * 100)}% mana regen
-
-
- )}
-
-
- {/* Combo Meter - Shows when climbing or has active combo */}
-
-
- {/* Current Study (if any) */}
- {store.currentStudyTarget && (
-
-
- {renderStudyProgress()}
-
- {/* Parallel Study Progress */}
- {store.parallelStudyTarget && (
-
-
-
-
-
- Parallel: {SKILLS_DEF[store.parallelStudyTarget.id]?.name}
- {store.parallelStudyTarget.type === 'skill' && ` Lv.${(store.skills[store.parallelStudyTarget.id] || 0) + 1}`}
-
-
-
store.cancelParallelStudy()}
- >
-
-
-
-
-
- {formatStudyTime(store.parallelStudyTarget.progress)} / {formatStudyTime(store.parallelStudyTarget.required)}
- 50% speed (Parallel Study)
-
-
- )}
-
-
- )}
-
- {/* Crafting Progress (if any) */}
- {(store.designProgress || store.preparationProgress || store.applicationProgress) && (
-
-
- {renderCraftingProgress()}
-
-
- )}
-
- {/* Spells Available */}
-
-
- Known Spells
-
-
-
- {Object.entries(store.spells)
- .filter(([, state]) => state.learned)
- .map(([id, state]) => {
- const def = SPELLS_DEF[id];
- if (!def) return null;
- const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
- const isActive = store.activeSpell === id;
- const canCast = canCastSpell(id);
-
- return (
-
store.setSpell(id)}
- >
-
- {def.name}
-
-
- {fmt(calcDamage(store, id))} dmg
-
-
- {formatSpellCost(def.cost)}
-
-
- );
- })}
-
-
-
-
- {/* Activity Log */}
-
-
- Activity Log
-
-
-
-
- {store.log.slice(0, 20).map((entry, i) => (
-
- {entry}
-
- ))}
-
-
-
-
-
- );
-
- // Spells Tab
- const renderSpellsTab = () => {
- // Get spells from equipment
- const equipmentSpellIds = store.getEquipmentSpells ? store.getEquipmentSpells() : [];
- const equipmentSpells = equipmentSpellIds.map(id => ({
- id,
- def: SPELLS_DEF[id],
- source: store.equippedInstances ?
- Object.entries(store.equippedInstances)
- .filter(([, instId]) => instId && store.equipmentInstances[instId]?.enchantments.some(e => {
- const effectDef = ENCHANTMENT_EFFECTS[e.effectId];
- return effectDef?.effect.type === 'spell' && effectDef.effect.spellId === id;
- }))
- .map(([slot]) => slot)
- .join(', ') : ''
- })).filter(s => s.def);
-
- return (
-
- {/* Equipment-Granted Spells */}
-
-
✨ Known Spells
-
- Spells are obtained by enchanting equipment with spell effects.
- Visit the Crafting tab to design and apply enchantments.
-
-
- {equipmentSpells.length > 0 ? (
-
- {equipmentSpells.map(({ id, def, source }) => {
- const isActive = store.activeSpell === id;
- const canCast = canAffordSpellCost(def.cost, store.rawMana, store.elements);
- const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
-
- return (
-
-
-
-
- {def.name}
-
- Equipment
-
-
-
-
- {def.elem !== 'raw' && {elemDef?.sym} {elemDef?.name} }
- ⚔️ {def.dmg} dmg
-
-
- Cost: {formatSpellCost(def.cost)}
-
- From: {source}
-
- {isActive ? (
- Active
- ) : (
- store.setSpell(id)}>
- Set Active
-
- )}
-
-
-
- );
- })}
-
- ) : (
-
-
No spells known yet
-
Enchant a staff with a spell effect to gain spells
-
- )}
-
-
- {/* Pact Spells (from guardian defeats) */}
- {store.signedPacts.length > 0 && (
-
-
🏆 Pact Spells
-
Spells earned through guardian pacts appear here.
-
- )}
-
- {/* Spell Reference - show all available spells for enchanting */}
-
-
📚 Spell Reference
-
- These spells can be applied to equipment through the enchanting system.
- Research enchantment effects in the Skills tab to unlock them for designing.
-
-
-
- {Object.entries(SPELLS_DEF).map(([id, def]) => {
- const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
- const isUnlocked = store.unlockedEffects?.includes(`spell_${id}`);
-
- return (
-
-
-
-
- {def.name}
-
-
- {def.tier > 0 && T{def.tier} }
- {isUnlocked && Unlocked }
-
-
-
-
-
- {def.elem !== 'raw' && {elemDef?.sym} {elemDef?.name} }
- ⚔️ {def.dmg} dmg
-
-
- Cost: {formatSpellCost(def.cost)}
-
- {def.desc && (
- {def.desc}
- )}
- {!isUnlocked && (
- Research to unlock for enchanting
- )}
-
-
- );
- })}
-
-
-
- );
- };
-
- // Lab Tab
- const renderLabTab = () => (
-
- {/* Elemental Mana Display */}
-
-
- Elemental Mana
-
-
-
- {Object.entries(store.elements)
- .filter(([, state]) => state.unlocked)
- .map(([id, state]) => {
- const def = ELEMENTS[id];
- const isSelected = convertTarget === id;
- return (
-
setConvertTarget(id)}
- >
-
{def?.sym}
-
{def?.name}
-
{state.current}/{state.max}
-
- );
- })}
-
-
-
-
- {/* Element Conversion */}
-
-
- Element Conversion
-
-
-
- Convert raw mana to elemental mana (100:1 ratio)
-
-
-
- store.convertMana(convertTarget, 1)}
- disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT}
- >
- +1 ({MANA_PER_ELEMENT})
-
- store.convertMana(convertTarget, 10)}
- disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT * 10}
- >
- +10 ({MANA_PER_ELEMENT * 10})
-
- store.convertMana(convertTarget, 100)}
- disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT * 100}
- >
- +100 ({MANA_PER_ELEMENT * 100})
-
-
-
-
-
- {/* Unlock Elements */}
-
-
- Unlock Elements
-
-
-
- Unlock new elemental affinities (500 mana each)
-
-
-
- {Object.entries(store.elements)
- .filter(([id, state]) => !state.unlocked && ELEMENTS[id]?.cat !== 'exotic')
- .map(([id]) => {
- const def = ELEMENTS[id];
- return (
-
-
{def?.sym}
-
{def?.name}
-
store.unlockElement(id)}
- >
- Unlock
-
-
- );
- })}
-
-
-
-
- {/* Composite Crafting */}
-
-
- Composite & Exotic Crafting
-
-
-
- {Object.entries(ELEMENTS)
- .filter(([, def]) => def.recipe)
- .map(([id, def]) => {
- const state = store.elements[id];
- const recipe = def.recipe!;
- const canCraft = recipe.every(
- (r) => (store.elements[r]?.current || 0) >= recipe.filter((x) => x === r).length
- );
-
- return (
-
-
-
{def.sym}
-
-
- {def.name}
-
-
{def.cat}
-
-
-
- {recipe.map((r) => ELEMENTS[r]?.sym).join(' + ')}
-
-
store.craftComposite(id)}
- >
- Craft
-
-
- );
- })}
-
-
-
-
- );
// Check if skill has milestone available
const hasMilestoneUpgrade = (skillId: string, level: number): { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null => {
@@ -1279,7 +404,12 @@ export default function ManaLoopGame() {
{store.currentStudyTarget && store.currentStudyTarget.type === 'skill' && (
- {renderStudyProgress()}
+
)}
@@ -2236,7 +1366,7 @@ export default function ManaLoopGame() {
{/* Calendar */}
- {renderCalendar()}
+
@@ -2281,7 +1411,13 @@ export default function ManaLoopGame() {
Current Action
- {renderActionButtons()}
+
@@ -2323,15 +1459,20 @@ export default function ManaLoopGame() {
- {renderSpireTab()}
+
- {renderSpellsTab()}
+
- {renderLabTab()}
+
void;
+}
+
+export function ActionButtons({
+ currentAction,
+ designProgress,
+ preparationProgress,
+ applicationProgress,
+ setAction,
+}: ActionButtonsProps) {
+ const actions: { id: GameAction; label: string; icon: typeof Swords }[] = [
+ { id: 'meditate', label: 'Meditate', icon: Sparkles },
+ { id: 'climb', label: 'Climb', icon: Swords },
+ { id: 'study', label: 'Study', icon: BookOpen },
+ { id: 'convert', label: 'Convert', icon: FlaskConical },
+ ];
+
+ const hasDesignProgress = designProgress !== null;
+ const hasPrepProgress = preparationProgress !== null;
+ const hasAppProgress = applicationProgress !== null;
+
+ return (
+
+
+ {actions.map(({ id, label, icon: Icon }) => (
+ setAction(id)}
+ >
+
+ {label}
+
+ ))}
+
+
+ {/* Crafting actions row - shown when there's active crafting progress */}
+ {(hasDesignProgress || hasPrepProgress || hasAppProgress) && (
+
+ hasDesignProgress && setAction('design')}
+ >
+
+ Design
+
+ hasPrepProgress && setAction('prepare')}
+ >
+
+ Prepare
+
+ hasAppProgress && setAction('enchant')}
+ >
+
+ Enchant
+
+
+ )}
+
+ );
+}
diff --git a/src/components/game/CalendarDisplay.tsx b/src/components/game/CalendarDisplay.tsx
new file mode 100644
index 0000000..c9e0995
--- /dev/null
+++ b/src/components/game/CalendarDisplay.tsx
@@ -0,0 +1,44 @@
+'use client';
+
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { MAX_DAY, INCURSION_START_DAY } from '@/lib/game/constants';
+
+interface CalendarDisplayProps {
+ currentDay: number;
+}
+
+export function CalendarDisplay({ currentDay }: CalendarDisplayProps) {
+ const days: React.ReactElement[] = [];
+
+ for (let d = 1; d <= MAX_DAY; d++) {
+ let dayClass = 'w-7 h-7 rounded text-xs flex items-center justify-center font-mono border transition-all ';
+
+ if (d < currentDay) {
+ dayClass += 'bg-blue-900/30 border-blue-800/50 text-blue-400';
+ } else if (d === currentDay) {
+ dayClass += 'bg-blue-600/40 border-blue-500 text-blue-300 shadow-lg shadow-blue-500/30';
+ } else {
+ dayClass += 'bg-gray-800/30 border-gray-700/50 text-gray-500';
+ }
+
+ if (d >= INCURSION_START_DAY) {
+ dayClass += ' border-red-600/50';
+ }
+
+ days.push(
+
+
+
+ {d}
+
+
+
+ Day {d}
+ {d >= INCURSION_START_DAY && Incursion Active
}
+
+
+ );
+ }
+
+ return <>{days}>;
+}
diff --git a/src/components/game/CraftingProgress.tsx b/src/components/game/CraftingProgress.tsx
new file mode 100644
index 0000000..66887ff
--- /dev/null
+++ b/src/components/game/CraftingProgress.tsx
@@ -0,0 +1,161 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import { Progress } from '@/components/ui/progress';
+import { Target, FlaskConical, Sparkles, Play, Pause, X } from 'lucide-react';
+import { fmt } from '@/lib/game/store';
+import { formatStudyTime } from '@/lib/game/formatting';
+import type { EquipmentInstance, EnchantmentDesign } from '@/lib/game/types';
+
+interface CraftingProgressProps {
+ designProgress: { designId: string; progress: number; required: number } | null;
+ preparationProgress: { equipmentInstanceId: string; progress: number; required: number; manaCostPaid: number } | null;
+ applicationProgress: { equipmentInstanceId: string; designId: string; progress: number; required: number; manaPerHour: number; paused: boolean } | null;
+ equipmentInstances: Record;
+ enchantmentDesigns: EnchantmentDesign[];
+ cancelDesign: () => void;
+ cancelPreparation: () => void;
+ pauseApplication: () => void;
+ resumeApplication: () => void;
+ cancelApplication: () => void;
+}
+
+export function CraftingProgress({
+ designProgress,
+ preparationProgress,
+ applicationProgress,
+ equipmentInstances,
+ enchantmentDesigns,
+ cancelDesign,
+ cancelPreparation,
+ pauseApplication,
+ resumeApplication,
+ cancelApplication,
+}: CraftingProgressProps) {
+ const progressSections: React.ReactNode[] = [];
+
+ // Design progress
+ if (designProgress) {
+ const progressPct = Math.min(100, (designProgress.progress / designProgress.required) * 100);
+ progressSections.push(
+
+
+
+
+
+ Designing Enchantment
+
+
+
+
+
+
+
+
+ {formatStudyTime(designProgress.progress)} / {formatStudyTime(designProgress.required)}
+ Design Time
+
+
+ );
+ }
+
+ // Preparation progress
+ if (preparationProgress) {
+ const progressPct = Math.min(100, (preparationProgress.progress / preparationProgress.required) * 100);
+ const instance = equipmentInstances[preparationProgress.equipmentInstanceId];
+ progressSections.push(
+
+
+
+
+
+ Preparing {instance?.name || 'Equipment'}
+
+
+
+
+
+
+
+
+ {formatStudyTime(preparationProgress.progress)} / {formatStudyTime(preparationProgress.required)}
+ Mana spent: {fmt(preparationProgress.manaCostPaid)}
+
+
+ );
+ }
+
+ // Application progress
+ if (applicationProgress) {
+ const progressPct = Math.min(100, (applicationProgress.progress / applicationProgress.required) * 100);
+ const instance = equipmentInstances[applicationProgress.equipmentInstanceId];
+ const design = enchantmentDesigns.find(d => d.id === applicationProgress.designId);
+ progressSections.push(
+
+
+
+
+
+ Enchanting {instance?.name || 'Equipment'}
+
+
+
+ {applicationProgress.paused ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+
+ {formatStudyTime(applicationProgress.progress)} / {formatStudyTime(applicationProgress.required)}
+ Mana/hr: {fmt(applicationProgress.manaPerHour)}
+
+ {design && (
+
+ Applying: {design.name}
+
+ )}
+
+ );
+ }
+
+ return progressSections.length > 0 ? (
+
+ {progressSections}
+
+ ) : null;
+}
diff --git a/src/components/game/ManaDisplay.tsx b/src/components/game/ManaDisplay.tsx
new file mode 100644
index 0000000..280049d
--- /dev/null
+++ b/src/components/game/ManaDisplay.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import { Progress } from '@/components/ui/progress';
+import { Card, CardContent } from '@/components/ui/card';
+import { Zap } from 'lucide-react';
+import { fmt, fmtDec } from '@/lib/game/store';
+
+interface ManaDisplayProps {
+ rawMana: number;
+ maxMana: number;
+ effectiveRegen: number;
+ meditationMultiplier: number;
+ clickMana: number;
+ isGathering: boolean;
+ onGatherStart: () => void;
+ onGatherEnd: () => void;
+}
+
+export function ManaDisplay({
+ rawMana,
+ maxMana,
+ effectiveRegen,
+ meditationMultiplier,
+ clickMana,
+ isGathering,
+ onGatherStart,
+ onGatherEnd,
+}: ManaDisplayProps) {
+ return (
+
+
+
+
+ {fmt(rawMana)}
+ / {fmt(maxMana)}
+
+
+ +{fmtDec(effectiveRegen)} mana/hr {meditationMultiplier > 1.01 && ({fmtDec(meditationMultiplier, 1)}x med) }
+
+
+
+
+
+
+
+ Gather +{clickMana} Mana
+ {isGathering && (Holding...) }
+
+
+
+ );
+}
diff --git a/src/components/game/StudyProgress.tsx b/src/components/game/StudyProgress.tsx
new file mode 100644
index 0000000..c4127b6
--- /dev/null
+++ b/src/components/game/StudyProgress.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import { Progress } from '@/components/ui/progress';
+import { BookOpen, X } from 'lucide-react';
+import { SKILLS_DEF, SPELLS_DEF } from '@/lib/game/constants';
+import { formatStudyTime } from '@/lib/game/formatting';
+import type { StudyTarget } from '@/lib/game/types';
+
+interface StudyProgressProps {
+ currentStudyTarget: StudyTarget | null;
+ skills: Record;
+ studySpeedMult: number;
+ cancelStudy: () => void;
+}
+
+export function StudyProgress({
+ currentStudyTarget,
+ skills,
+ studySpeedMult,
+ cancelStudy,
+}: StudyProgressProps) {
+ if (!currentStudyTarget) return null;
+
+ const target = currentStudyTarget;
+ const progressPct = Math.min(100, (target.progress / target.required) * 100);
+ const isSkill = target.type === 'skill';
+ const def = isSkill ? SKILLS_DEF[target.id] : SPELLS_DEF[target.id];
+ const currentLevel = isSkill ? (skills[target.id] || 0) : 0;
+
+ return (
+
+
+
+
+
+ {def?.name}
+ {isSkill && ` Lv.${currentLevel + 1}`}
+
+
+
+
+
+
+
+
+ {formatStudyTime(target.progress)} / {formatStudyTime(target.required)}
+ {studySpeedMult.toFixed(1)}x speed
+
+
+ );
+}
diff --git a/src/components/game/TimeDisplay.tsx b/src/components/game/TimeDisplay.tsx
new file mode 100644
index 0000000..5916e52
--- /dev/null
+++ b/src/components/game/TimeDisplay.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import { Play, Pause } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { fmt } from '@/lib/game/store';
+import { formatHour } from '@/lib/game/formatting';
+
+interface TimeDisplayProps {
+ day: number;
+ hour: number;
+ insight: number;
+ paused: boolean;
+ onTogglePause: () => void;
+}
+
+export function TimeDisplay({
+ day,
+ hour,
+ insight,
+ paused,
+ onTogglePause,
+}: TimeDisplayProps) {
+ return (
+
+
+
+ Day {day}
+
+
+ {formatHour(hour)}
+
+
+
+
+
+ {fmt(insight)}
+
+
Insight
+
+
+
+ {paused ? : }
+
+
+ );
+}
diff --git a/src/components/game/index.ts b/src/components/game/index.ts
index 3c0b16d..a280615 100755
--- a/src/components/game/index.ts
+++ b/src/components/game/index.ts
@@ -1,7 +1,17 @@
// ─── Game Components Index ──────────────────────────────────────────────────────
// Re-exports all game tab components for cleaner imports
+// Tab components
export { CraftingTab } from './tabs/CraftingTab';
export { SpireTab } from './tabs/SpireTab';
export { SpellsTab } from './tabs/SpellsTab';
export { LabTab } from './tabs/LabTab';
+
+// UI components
+export { ActionButtons } from './ActionButtons';
+export { CalendarDisplay } from './CalendarDisplay';
+export { ComboMeter } from './ComboMeter';
+export { CraftingProgress } from './CraftingProgress';
+export { StudyProgress } from './StudyProgress';
+export { ManaDisplay } from './ManaDisplay';
+export { TimeDisplay } from './TimeDisplay';
diff --git a/src/components/game/tabs/SpireTab.tsx b/src/components/game/tabs/SpireTab.tsx
index f6fada5..9e34fdd 100755
--- a/src/components/game/tabs/SpireTab.tsx
+++ b/src/components/game/tabs/SpireTab.tsx
@@ -6,13 +6,47 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
-import { Swords, Sparkles, BookOpen, ChevronUp, ChevronDown, ArrowUp, ArrowDown, RefreshCw } from 'lucide-react';
-import type { GameState, GameAction } from '@/lib/game/types';
-import { ELEMENTS, GUARDIANS, SPELLS_DEF, MAX_DAY, INCURSION_START_DAY } from '@/lib/game/constants';
+import { TooltipProvider } from '@/components/ui/tooltip';
+import { Swords, BookOpen, ChevronUp, ChevronDown, RotateCcw, X } from 'lucide-react';
+import type { GameState, GameAction, EquipmentSpellState, StudyTarget } from '@/lib/game/types';
+import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF } from '@/lib/game/constants';
import { fmt, fmtDec, getFloorElement, calcDamage, canAffordSpellCost } from '@/lib/game/store';
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
-import type { ComputedEffects } from '@/lib/game/effects';
+import { ComboMeter, CraftingProgress, StudyProgress } from '@/components/game';
+import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
+import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
+
+// Helper to get active spells from equipped caster weapons
+function getActiveEquipmentSpells(
+ equippedInstances: Record,
+ equipmentInstances: Record }>
+): Array<{ spellId: string; equipmentId: string }> {
+ const spells: Array<{ spellId: string; equipmentId: string }> = [];
+ const weaponSlots = ['mainHand', 'offHand'] as const;
+
+ for (const slot of weaponSlots) {
+ const instanceId = equippedInstances[slot];
+ if (!instanceId) continue;
+
+ const instance = equipmentInstances[instanceId];
+ if (!instance) continue;
+
+ const equipType = EQUIPMENT_TYPES[instance.typeId];
+ if (!equipType || equipType.category !== 'caster') continue;
+
+ for (const ench of instance.enchantments) {
+ const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
+ if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) {
+ spells.push({
+ spellId: effectDef.effect.spellId,
+ equipmentId: instanceId,
+ });
+ }
+ }
+ }
+
+ return spells;
+}
interface SpireTabProps {
store: GameState & {
@@ -22,80 +56,42 @@ interface SpireTabProps {
cancelParallelStudy: () => void;
setClimbDirection: (direction: 'up' | 'down') => void;
changeFloor: (direction: 'up' | 'down') => void;
+ cancelDesign?: () => void;
+ cancelPreparation?: () => void;
+ pauseApplication?: () => void;
+ resumeApplication?: () => void;
+ cancelApplication?: () => void;
};
- upgradeEffects: ComputedEffects;
- maxMana: number;
- effectiveRegen: number;
- incursionStrength: number;
- dps: number;
+ totalDPS: number;
studySpeedMult: number;
- formatTime: (hour: number) => string;
+ incursionStrength: number;
}
export function SpireTab({
store,
- upgradeEffects,
- maxMana,
- effectiveRegen,
- incursionStrength,
- dps,
+ totalDPS,
studySpeedMult,
- formatTime,
+ incursionStrength,
}: SpireTabProps) {
const floorElem = getFloorElement(store.currentFloor);
const floorElemDef = ELEMENTS[floorElem];
const isGuardianFloor = !!GUARDIANS[store.currentFloor];
const currentGuardian = GUARDIANS[store.currentFloor];
- const activeSpellDef = SPELLS_DEF[store.activeSpell];
const climbDirection = store.climbDirection || 'up';
const clearedFloors = store.clearedFloors || {};
// Check if current floor is cleared (for respawn indicator)
const isFloorCleared = clearedFloors[store.currentFloor];
+ // Get active equipment spells
+ const activeEquipmentSpells = getActiveEquipmentSpells(store.equippedInstances, store.equipmentInstances);
+
const canCastSpell = (spellId: string): boolean => {
const spell = SPELLS_DEF[spellId];
if (!spell) return false;
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
};
- // Render study progress
- const renderStudyProgress = () => {
- if (!store.currentStudyTarget) return null;
-
- const target = store.currentStudyTarget;
- const progressPct = Math.min(100, (target.progress / target.required) * 100);
- const isSkill = target.type === 'skill';
- const def = isSkill ? SPELLS_DEF[target.id] : SPELLS_DEF[target.id];
-
- return (
-
-
-
-
-
- {def?.name}
- {isSkill && ` Lv.${(store.skills[target.id] || 0) + 1}`}
-
-
-
store.cancelStudy()}
- >
- ✕
-
-
-
-
- {formatStudyTime(target.progress)} / {formatStudyTime(target.required)}
- {studySpeedMult.toFixed(1)}x speed
-
-
- );
- };
-
return (
@@ -138,16 +134,16 @@ export function SpireTab({
{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP
- DPS: {store.currentAction === 'climb' && canCastSpell(store.activeSpell) ? fmtDec(dps) : '—'}
+ DPS: {store.currentAction === 'climb' && activeEquipmentSpells.length > 0 ? fmtDec(totalDPS) : '—'}
- {/* Floor Navigation */}
+ {/* Floor Navigation Controls */}
-
Direction
+
Auto-Direction
store.setClimbDirection('up')}
>
-
+
Up
store.setClimbDirection('down')}
>
-
+
Down
@@ -179,7 +175,7 @@ export function SpireTab({
onClick={() => store.changeFloor('down')}
>
- Go Down
+ Descend
store.changeFloor('up')}
>
- Go Up
+ Ascend
{isFloorCleared && (
-
- ⚠️ Floor will respawn when you return
+
+
+ Floor will respawn when you leave and return
)}
@@ -209,51 +206,74 @@ export function SpireTab({
- {/* Active Spell Card */}
+ {/* Active Spells Card - Shows all spells from equipped weapons */}
- Active Spell
+
+ Active Spells ({activeEquipmentSpells.length})
+
- {activeSpellDef ? (
- <>
-
- {activeSpellDef.name}
- {activeSpellDef.tier === 0 && Basic }
- {activeSpellDef.tier >= 4 && Legendary }
-
-
- ⚔️ {fmt(calcDamage(store, store.activeSpell))} dmg •
-
- {' '}{formatSpellCost(activeSpellDef.cost)}
-
- {' '}• ⚡ {(activeSpellDef.castSpeed || 1).toFixed(1)} casts/hr
-
-
- {/* Cast progress bar when climbing */}
- {store.currentAction === 'climb' && (
-
-
-
Cast Progress
-
{((store.castProgress || 0) * 100).toFixed(0)}%
+ {activeEquipmentSpells.length > 0 ? (
+
+ {activeEquipmentSpells.map(({ spellId, equipmentId }) => {
+ const spellDef = SPELLS_DEF[spellId];
+ if (!spellDef) return null;
+
+ const spellState = store.equipmentSpellStates?.find(
+ s => s.spellId === spellId && s.sourceEquipment === equipmentId
+ );
+ const progress = spellState?.castProgress || 0;
+ const canCast = canAffordSpellCost(spellDef.cost, store.rawMana, store.elements);
+
+ return (
+
+
+
+ {spellDef.name}
+ {spellDef.tier === 0 && Basic }
+ {spellDef.tier >= 4 && Legendary }
+
+
+ {canCast ? '✓' : '✗'}
+
+
+
+ ⚔️ {fmt(calcDamage(store, spellId, floorElem))} dmg •
+
+ {' '}{formatSpellCost(spellDef.cost)}
+
+ {' '}• ⚡ {(spellDef.castSpeed || 1).toFixed(1)}/hr
+
+
+ {/* Cast progress bar when climbing */}
+ {store.currentAction === 'climb' && (
+
+
+ Cast
+ {(progress * 100).toFixed(0)}%
+
+
+
+ )}
+
+ {spellDef.effects && spellDef.effects.length > 0 && (
+
+ {spellDef.effects.map((eff, i) => (
+
+ {eff.type === 'lifesteal' && `🩸 ${Math.round(eff.value * 100)}%`}
+ {eff.type === 'burn' && `🔥 Burn`}
+ {eff.type === 'freeze' && `❄️ Freeze`}
+
+ ))}
+
+ )}
-
-
- )}
-
- {activeSpellDef.desc && (
-
{activeSpellDef.desc}
- )}
- >
- ) : (
-
No spell selected
- )}
-
- {/* Can cast indicator */}
- {activeSpellDef && (
-
- {canCastSpell(store.activeSpell) ? '✓ Can cast' : '✗ Insufficient mana'}
+ );
+ })}
+ ) : (
+
No spells on equipped weapons. Enchant a staff with spell effects.
)}
{incursionStrength > 0 && (
@@ -267,11 +287,67 @@ export function SpireTab({
+ {/* Combo Meter - Shows when climbing or has active combo */}
+
+
{/* Current Study (if any) */}
{store.currentStudyTarget && (
- {renderStudyProgress()}
+
+
+ {/* Parallel Study Progress */}
+ {store.parallelStudyTarget && (
+
+
+
+
+
+ Parallel: {SKILLS_DEF[store.parallelStudyTarget.id]?.name}
+ {store.parallelStudyTarget.type === 'skill' && ` Lv.${(store.skills[store.parallelStudyTarget.id] || 0) + 1}`}
+
+
+
store.cancelParallelStudy()}
+ >
+
+
+
+
+
+ {formatStudyTime(store.parallelStudyTarget.progress)} / {formatStudyTime(store.parallelStudyTarget.required)}
+ 50% speed (Parallel Study)
+
+
+ )}
+
+
+ )}
+
+ {/* Crafting Progress (if any) */}
+ {(store.designProgress || store.preparationProgress || store.applicationProgress) && (
+
+
+
)}
diff --git a/src/components/game/tabs/index.ts b/src/components/game/tabs/index.ts
new file mode 100644
index 0000000..85594e9
--- /dev/null
+++ b/src/components/game/tabs/index.ts
@@ -0,0 +1,7 @@
+// ─── Tab Components Index ──────────────────────────────────────────────────────
+// Re-exports all tab components for cleaner imports
+
+export { CraftingTab } from './CraftingTab';
+export { SpireTab } from './SpireTab';
+export { SpellsTab } from './SpellsTab';
+export { LabTab } from './LabTab';
diff --git a/src/lib/game/computed-stats.ts b/src/lib/game/computed-stats.ts
new file mode 100644
index 0000000..edad755
--- /dev/null
+++ b/src/lib/game/computed-stats.ts
@@ -0,0 +1,397 @@
+// ─── Computed Stats and Utility Functions ───────────────────────────────────────
+// This module contains all computed stat functions and utility helpers
+// extracted from the main store for better organization
+
+import type { GameState, SpellCost, EquipmentInstance } from './types';
+import {
+ GUARDIANS,
+ SPELLS_DEF,
+ FLOOR_ELEM_CYCLE,
+ HOURS_PER_TICK,
+ MAX_DAY,
+ INCURSION_START_DAY,
+ ELEMENT_OPPOSITES,
+} from './constants';
+import type { ComputedEffects } from './upgrade-effects';
+import { getUnifiedEffects, type UnifiedEffects } from './effects';
+import { EQUIPMENT_TYPES } from './data/equipment';
+import { ENCHANTMENT_EFFECTS } from './data/enchantment-effects';
+
+// ─── Default Effects Constant ───────────────────────────────────────────────────
+
+// Default empty effects for when effects aren't provided
+export const DEFAULT_EFFECTS: ComputedEffects = {
+ maxManaMultiplier: 1,
+ maxManaBonus: 0,
+ regenMultiplier: 1,
+ regenBonus: 0,
+ clickManaMultiplier: 1,
+ clickManaBonus: 0,
+ meditationEfficiency: 1,
+ spellCostMultiplier: 1,
+ conversionEfficiency: 1,
+ baseDamageMultiplier: 1,
+ baseDamageBonus: 0,
+ attackSpeedMultiplier: 1,
+ critChanceBonus: 0,
+ critDamageMultiplier: 1.5,
+ elementalDamageMultiplier: 1,
+ studySpeedMultiplier: 1,
+ studyCostMultiplier: 1,
+ progressRetention: 0,
+ instantStudyChance: 0,
+ freeStudyChance: 0,
+ elementCapMultiplier: 1,
+ elementCapBonus: 0,
+ conversionCostMultiplier: 1,
+ doubleCraftChance: 0,
+ permanentRegenBonus: 0,
+ specials: new Set(),
+ activeUpgrades: [],
+};
+
+// ─── Number Formatting Functions ────────────────────────────────────────────────
+
+export function fmt(n: number): string {
+ if (!isFinite(n) || isNaN(n)) return '0';
+ if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
+ if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
+ return Math.floor(n).toString();
+}
+
+export function fmtDec(n: number, d: number = 1): string {
+ return isFinite(n) ? n.toFixed(d) : '0';
+}
+
+// ─── Floor Functions ────────────────────────────────────────────────────────────
+
+export function getFloorMaxHP(floor: number): number {
+ if (GUARDIANS[floor]) return GUARDIANS[floor].hp;
+ // Improved scaling: slower early game, faster late game
+ const baseHP = 100;
+ const floorScaling = floor * 50;
+ const exponentialScaling = Math.pow(floor, 1.7);
+ return Math.floor(baseHP + floorScaling + exponentialScaling);
+}
+
+export function getFloorElement(floor: number): string {
+ return FLOOR_ELEM_CYCLE[(floor - 1) % 8];
+}
+
+// ─── Equipment Spell Helper ─────────────────────────────────────────────────────
+
+// Get all spells from equipped caster weapons (staves, wands, etc.)
+// Returns array of { spellId, equipmentInstanceId }
+export function getActiveEquipmentSpells(
+ equippedInstances: Record
,
+ equipmentInstances: Record
+): Array<{ spellId: string; equipmentId: string }> {
+ const spells: Array<{ spellId: string; equipmentId: string }> = [];
+
+ // Check main hand and off hand for caster equipment
+ const weaponSlots = ['mainHand', 'offHand'] as const;
+
+ for (const slot of weaponSlots) {
+ const instanceId = equippedInstances[slot];
+ if (!instanceId) continue;
+
+ const instance = equipmentInstances[instanceId];
+ if (!instance) continue;
+
+ // Check if this is a caster-type equipment
+ const equipType = EQUIPMENT_TYPES[instance.typeId];
+ if (!equipType || equipType.category !== 'caster') continue;
+
+ // Get spells from enchantments
+ for (const ench of instance.enchantments) {
+ const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
+ if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) {
+ spells.push({
+ spellId: effectDef.effect.spellId,
+ equipmentId: instanceId,
+ });
+ }
+ }
+ }
+
+ return spells;
+}
+
+// ─── Skill Level Helper ─────────────────────────────────────────────────────────
+
+// Helper to get effective skill level accounting for tiers
+export function getEffectiveSkillLevel(
+ skills: Record,
+ baseSkillId: string,
+ skillTiers: Record = {}
+): { level: number; tier: number; tierMultiplier: number } {
+ // Find the highest tier the player has for this base skill
+ const currentTier = skillTiers[baseSkillId] || 1;
+
+ // Look for the tiered skill ID (e.g., manaFlow_t2)
+ const tieredSkillId = currentTier > 1 ? `${baseSkillId}_t${currentTier}` : baseSkillId;
+ const level = skills[tieredSkillId] || skills[baseSkillId] || 0;
+
+ // Tier multiplier: each tier is 10x more powerful
+ const tierMultiplier = Math.pow(10, currentTier - 1);
+
+ return { level, tier: currentTier, tierMultiplier };
+}
+
+// ─── Computed Stat Functions ────────────────────────────────────────────────────
+
+export function computeMaxMana(
+ state: Pick,
+ effects?: ComputedEffects | UnifiedEffects
+): number {
+ const pu = state.prestigeUpgrades;
+ const base =
+ 100 +
+ (state.skills.manaWell || 0) * 100 +
+ (pu.manaWell || 0) * 500;
+
+ // If effects not provided, compute unified effects (includes equipment)
+ if (!effects && state.equipmentInstances && state.equippedInstances) {
+ effects = getUnifiedEffects(state as any);
+ }
+
+ // Apply effects if available (now includes equipment bonuses)
+ if (effects) {
+ return Math.floor((base + effects.maxManaBonus) * effects.maxManaMultiplier);
+ }
+ return base;
+}
+
+export function computeElementMax(
+ state: Pick,
+ effects?: ComputedEffects
+): number {
+ const pu = state.prestigeUpgrades;
+ const base = 10 + (state.skills.elemAttune || 0) * 50 + (pu.elementalAttune || 0) * 25;
+
+ // Apply upgrade effects if provided
+ if (effects) {
+ return Math.floor((base + effects.elementCapBonus) * effects.elementCapMultiplier);
+ }
+ return base;
+}
+
+export function computeRegen(
+ state: Pick,
+ effects?: ComputedEffects | UnifiedEffects
+): number {
+ const pu = state.prestigeUpgrades;
+ const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1;
+ const base =
+ 2 +
+ (state.skills.manaFlow || 0) * 1 +
+ (state.skills.manaSpring || 0) * 2 +
+ (pu.manaFlow || 0) * 0.5;
+
+ let regen = base * temporalBonus;
+
+ // If effects not provided, compute unified effects (includes equipment)
+ if (!effects && state.equipmentInstances && state.equippedInstances) {
+ effects = getUnifiedEffects(state as any);
+ }
+
+ // Apply effects if available (now includes equipment bonuses)
+ if (effects) {
+ regen = (regen + effects.regenBonus + effects.permanentRegenBonus) * effects.regenMultiplier;
+ }
+
+ return regen;
+}
+
+/**
+ * Compute regen with dynamic special effects (needs current mana, max mana, incursion)
+ */
+export function computeEffectiveRegen(
+ state: Pick,
+ effects?: ComputedEffects | UnifiedEffects
+): number {
+ // Base regen from existing function
+ let regen = computeRegen(state, effects);
+
+ const maxMana = computeMaxMana(state, effects);
+ const currentMana = state.rawMana;
+ const incursionStrength = state.incursionStrength || 0;
+
+ // Apply incursion penalty
+ regen *= (1 - incursionStrength);
+
+ return regen;
+}
+
+export function computeClickMana(
+ state: Pick,
+ effects?: ComputedEffects | UnifiedEffects
+): number {
+ const base =
+ 1 +
+ (state.skills.manaTap || 0) * 1 +
+ (state.skills.manaSurge || 0) * 3;
+
+ // If effects not provided, compute unified effects (includes equipment)
+ if (!effects && state.equipmentInstances && state.equippedInstances) {
+ effects = getUnifiedEffects(state as any);
+ }
+
+ // Apply effects if available (now includes equipment bonuses)
+ if (effects) {
+ return Math.floor((base + effects.clickManaBonus) * effects.clickManaMultiplier);
+ }
+ return base;
+}
+
+// ─── Damage Calculation Helpers ─────────────────────────────────────────────────
+
+// Elemental damage bonus: +50% if spell element opposes floor element (super effective)
+// -25% if spell element matches its own opposite (weak)
+export function getElementalBonus(spellElem: string, floorElem: string): number {
+ if (spellElem === 'raw') return 1.0; // Raw mana has no elemental bonus
+
+ if (spellElem === floorElem) return 1.25; // Same element: +25% damage
+
+ // Check for super effective first: spell is the opposite of floor
+ // e.g., casting water (opposite of fire) at fire floor = super effective
+ if (ELEMENT_OPPOSITES[floorElem] === spellElem) return 1.5; // Super effective: +50% damage
+
+ // Check for weak: spell's opposite matches floor
+ // e.g., casting fire (whose opposite is water) at water floor = weak
+ if (ELEMENT_OPPOSITES[spellElem] === floorElem) return 0.75; // Weak: -25% damage
+
+ return 1.0; // Neutral
+}
+
+export function calcDamage(
+ state: Pick,
+ spellId: string,
+ floorElem?: string
+): number {
+ const sp = SPELLS_DEF[spellId];
+ if (!sp) return 5;
+ const skills = state.skills;
+ const baseDmg = sp.dmg + (skills.combatTrain || 0) * 5;
+ const pct = 1 + (skills.arcaneFury || 0) * 0.1;
+
+ // Elemental mastery bonus
+ const elemMasteryBonus = 1 + (skills.elementalMastery || 0) * 0.15;
+
+ // Guardian bane bonus
+ const guardianBonus = floorElem && GUARDIANS[Object.values(GUARDIANS).find(g => g.element === floorElem)?.hp ? 0 : 0]
+ ? 1 + (skills.guardianBane || 0) * 0.2
+ : 1;
+
+ const critChance = (skills.precision || 0) * 0.05;
+ const pactMult = state.signedPacts.reduce(
+ (m, f) => m * (GUARDIANS[f]?.pact || 1),
+ 1
+ );
+
+ let damage = baseDmg * pct * pactMult * elemMasteryBonus;
+
+ // Apply elemental bonus if floor element provided
+ if (floorElem) {
+ damage *= getElementalBonus(sp.elem, floorElem);
+ }
+
+ // Apply crit
+ if (Math.random() < critChance) {
+ damage *= 1.5;
+ }
+
+ return damage;
+}
+
+// ─── Insight Calculation ────────────────────────────────────────────────────────
+
+export function calcInsight(state: Pick): number {
+ const pu = state.prestigeUpgrades;
+ const skillBonus = 1 + (state.skills.insightHarvest || 0) * 0.1;
+ const mult = (1 + (pu.insightAmp || 0) * 0.25) * skillBonus;
+ return Math.floor(
+ (state.maxFloorReached * 15 + state.totalManaGathered / 500 + state.signedPacts.length * 150) * mult
+ );
+}
+
+// ─── Meditation Bonus ───────────────────────────────────────────────────────────
+
+// Meditation bonus now affects regen rate directly
+export function getMeditationBonus(meditateTicks: number, skills: Record, meditationEfficiency: number = 1): number {
+ const hasMeditation = skills.meditation === 1;
+ const hasDeepTrance = skills.deepTrance === 1;
+ const hasVoidMeditation = skills.voidMeditation === 1;
+
+ const hours = meditateTicks * HOURS_PER_TICK;
+
+ // Base meditation: ramps up over 4 hours to 1.5x
+ let bonus = 1 + Math.min(hours / 4, 0.5);
+
+ // With Meditation Focus: up to 2.5x after 4 hours
+ if (hasMeditation && hours >= 4) {
+ bonus = 2.5;
+ }
+
+ // With Deep Trance: up to 3.0x after 6 hours
+ if (hasDeepTrance && hours >= 6) {
+ bonus = 3.0;
+ }
+
+ // With Void Meditation: up to 5.0x after 8 hours
+ if (hasVoidMeditation && hours >= 8) {
+ bonus = 5.0;
+ }
+
+ // Apply meditation efficiency from upgrades (Deep Wellspring, etc.)
+ bonus *= meditationEfficiency;
+
+ return bonus;
+}
+
+// ─── Incursion Strength ─────────────────────────────────────────────────────────
+
+export function getIncursionStrength(day: number, hour: number): number {
+ if (day < INCURSION_START_DAY) return 0;
+ const totalHours = (day - INCURSION_START_DAY) * 24 + hour;
+ const maxHours = (MAX_DAY - INCURSION_START_DAY) * 24;
+ return Math.min(0.95, (totalHours / maxHours) * 0.95);
+}
+
+// ─── Spell Cost Helpers ─────────────────────────────────────────────────────────
+
+// Check if player can afford spell cost
+export function canAffordSpellCost(
+ cost: SpellCost,
+ rawMana: number,
+ elements: Record
+): boolean {
+ if (cost.type === 'raw') {
+ return rawMana >= cost.amount;
+ } else {
+ const elem = elements[cost.element || ''];
+ return elem && elem.unlocked && elem.current >= cost.amount;
+ }
+}
+
+// Deduct spell cost from appropriate mana pool
+export function deductSpellCost(
+ cost: SpellCost,
+ rawMana: number,
+ elements: Record
+): { rawMana: number; elements: Record } {
+ const newElements = { ...elements };
+
+ if (cost.type === 'raw') {
+ return { rawMana: rawMana - cost.amount, elements: newElements };
+ } else if (cost.element && newElements[cost.element]) {
+ newElements[cost.element] = {
+ ...newElements[cost.element],
+ current: newElements[cost.element].current - cost.amount
+ };
+ return { rawMana, elements: newElements };
+ }
+
+ return { rawMana, elements: newElements };
+}
diff --git a/src/lib/game/formatting.ts b/src/lib/game/formatting.ts
index 6b91d9c..0da5648 100755
--- a/src/lib/game/formatting.ts
+++ b/src/lib/game/formatting.ts
@@ -4,6 +4,9 @@
import { ELEMENTS } from '@/lib/game/constants';
import type { SpellCost } from '@/lib/game/types';
+// Re-export number formatting functions from computed-stats.ts
+export { fmt, fmtDec } from './computed-stats';
+
/**
* Format a spell cost for display
*/
diff --git a/src/lib/game/navigation-slice.ts b/src/lib/game/navigation-slice.ts
new file mode 100644
index 0000000..dc254f2
--- /dev/null
+++ b/src/lib/game/navigation-slice.ts
@@ -0,0 +1,63 @@
+// ─── Navigation Slice ─────────────────────────────────────────────────────────
+// Actions for floor navigation: climbing direction and manual floor changes
+
+import type { GameState } from './types';
+import { getFloorMaxHP } from './computed-stats';
+
+// ─── Navigation Actions Interface ─────────────────────────────────────────────
+
+export interface NavigationActions {
+ // Floor Navigation
+ setClimbDirection: (direction: 'up' | 'down') => void;
+ changeFloor: (direction: 'up' | 'down') => void;
+}
+
+// ─── Navigation Slice Factory ─────────────────────────────────────────────────
+
+export function createNavigationSlice(
+ set: (partial: Partial | ((state: GameState) => Partial)) => void,
+ get: () => GameState
+): NavigationActions {
+ return {
+ // Set the climbing direction (up or down)
+ setClimbDirection: (direction: 'up' | 'down') => {
+ set({ climbDirection: direction });
+ },
+
+ // Manually change floors by one
+ changeFloor: (direction: 'up' | 'down') => {
+ const state = get();
+ const currentFloor = state.currentFloor;
+
+ // Calculate next floor
+ const nextFloor = direction === 'up'
+ ? Math.min(currentFloor + 1, 100)
+ : Math.max(currentFloor - 1, 1);
+
+ // Can't stay on same floor
+ if (nextFloor === currentFloor) return;
+
+ // Mark current floor as cleared (it will respawn when we come back)
+ const clearedFloors = { ...state.clearedFloors };
+ clearedFloors[currentFloor] = true;
+
+ // Check if next floor was cleared (needs respawn)
+ const nextFloorCleared = clearedFloors[nextFloor];
+ if (nextFloorCleared) {
+ // Respawn the floor
+ delete clearedFloors[nextFloor];
+ }
+
+ set({
+ currentFloor: nextFloor,
+ floorMaxHP: getFloorMaxHP(nextFloor),
+ floorHP: getFloorMaxHP(nextFloor),
+ maxFloorReached: Math.max(state.maxFloorReached, nextFloor),
+ clearedFloors,
+ climbDirection: direction,
+ equipmentSpellStates: state.equipmentSpellStates.map(s => ({ ...s, castProgress: 0 })),
+ log: [`🚶 Moved to floor ${nextFloor}${nextFloorCleared ? ' (respawned)' : ''}.`, ...state.log.slice(0, 49)],
+ });
+ },
+ };
+}
diff --git a/src/lib/game/store.ts b/src/lib/game/store.ts
index 11bc0ee..9a40c46 100755
--- a/src/lib/game/store.ts
+++ b/src/lib/game/store.ts
@@ -2,7 +2,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
-import type { GameState, GameAction, StudyTarget, SpellCost, SkillUpgradeChoice, EquipmentSlot, EquipmentInstance, EnchantmentDesign, DesignEffect, LootInventory } from './types';
+import type { GameState, GameAction, StudyTarget, SkillUpgradeChoice, EquipmentSlot, EnchantmentDesign, DesignEffect, LootInventory } from './types';
import {
ELEMENTS,
GUARDIANS,
@@ -17,19 +17,13 @@ import {
INCURSION_START_DAY,
MANA_PER_ELEMENT,
getStudySpeedMultiplier,
- getStudyCostMultiplier,
ELEMENT_OPPOSITES,
EFFECT_RESEARCH_MAPPING,
BASE_UNLOCKED_EFFECTS,
ENCHANTING_UNLOCK_EFFECTS,
} from './constants';
-import { computeEffects, hasSpecial, SPECIAL_EFFECTS, type ComputedEffects } from './upgrade-effects';
-import {
- computeAllEffects,
- getUnifiedEffects,
- computeEquipmentEffects,
- type UnifiedEffects
-} from './effects';
+import { hasSpecial, SPECIAL_EFFECTS } from './upgrade-effects';
+import { getUnifiedEffects } from './effects';
import { SKILL_EVOLUTION_PATHS } from './skill-evolution';
import {
createStartingEquipment,
@@ -37,7 +31,6 @@ import {
getSpellsFromEquipment,
type CraftingActions
} from './crafting-slice';
-import { EQUIPMENT_TYPES } from './data/equipment';
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
import {
createFamiliarSlice,
@@ -47,228 +40,56 @@ import {
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';
-// Default empty effects for when effects aren't provided
-const DEFAULT_EFFECTS: ComputedEffects = {
- maxManaMultiplier: 1,
- maxManaBonus: 0,
- regenMultiplier: 1,
- regenBonus: 0,
- clickManaMultiplier: 1,
- clickManaBonus: 0,
- meditationEfficiency: 1,
- spellCostMultiplier: 1,
- conversionEfficiency: 1,
- baseDamageMultiplier: 1,
- baseDamageBonus: 0,
- attackSpeedMultiplier: 1,
- critChanceBonus: 0,
- critDamageMultiplier: 1.5,
- elementalDamageMultiplier: 1,
- studySpeedMultiplier: 1,
- studyCostMultiplier: 1,
- progressRetention: 0,
- instantStudyChance: 0,
- freeStudyChance: 0,
- elementCapMultiplier: 1,
- elementCapBonus: 0,
- conversionCostMultiplier: 1,
- doubleCraftChance: 0,
- permanentRegenBonus: 0,
- specials: new Set(),
- activeUpgrades: [],
+// Re-export formatting functions and computed stats for backward compatibility
+export {
+ fmt,
+ fmtDec,
+ getFloorElement,
+ computeMaxMana,
+ computeRegen,
+ computeClickMana,
+ calcDamage,
+ getMeditationBonus,
+ getIncursionStrength,
+ canAffordSpellCost,
+ getFloorMaxHP,
};
-// ─── Helper Functions ─────────────────────────────────────────────────────────
-
-export function fmt(n: number): string {
- if (!isFinite(n) || isNaN(n)) return '0';
- if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
- if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
- if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
- return Math.floor(n).toString();
-}
-
-export function fmtDec(n: number, d: number = 1): string {
- return isFinite(n) ? n.toFixed(d) : '0';
-}
-
-export function getFloorMaxHP(floor: number): number {
- if (GUARDIANS[floor]) return GUARDIANS[floor].hp;
- // Improved scaling: slower early game, faster late game
- const baseHP = 100;
- const floorScaling = floor * 50;
- const exponentialScaling = Math.pow(floor, 1.7);
- return Math.floor(baseHP + floorScaling + exponentialScaling);
-}
-
-export function getFloorElement(floor: number): string {
- return FLOOR_ELEM_CYCLE[(floor - 1) % 8];
-}
-
-// Get all spells from equipped caster weapons (staves, wands, etc.)
-// Returns array of { spellId, equipmentInstanceId }
-function getActiveEquipmentSpells(
- equippedInstances: Record,
- equipmentInstances: Record
-): Array<{ spellId: string; equipmentId: string }> {
- const spells: Array<{ spellId: string; equipmentId: string }> = [];
-
- // Check main hand and off hand for caster equipment
- const weaponSlots = ['mainHand', 'offHand'] as const;
-
- for (const slot of weaponSlots) {
- const instanceId = equippedInstances[slot];
- if (!instanceId) continue;
-
- const instance = equipmentInstances[instanceId];
- if (!instance) continue;
-
- // Check if this is a caster-type equipment
- const equipType = EQUIPMENT_TYPES[instance.typeId];
- if (!equipType || equipType.category !== 'caster') continue;
-
- // Get spells from enchantments
- for (const ench of instance.enchantments) {
- const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
- if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) {
- spells.push({
- spellId: effectDef.effect.spellId,
- equipmentId: instanceId,
- });
- }
- }
- }
-
- return spells;
-}
-
-// ─── Computed Stats Functions ─────────────────────────────────────────────────
-
-// Helper to get effective skill level accounting for tiers
-function getEffectiveSkillLevel(
- skills: Record,
- baseSkillId: string,
- skillTiers: Record = {}
-): { level: number; tier: number; tierMultiplier: number } {
- // Find the highest tier the player has for this base skill
- const currentTier = skillTiers[baseSkillId] || 1;
-
- // Look for the tiered skill ID (e.g., manaFlow_t2)
- const tieredSkillId = currentTier > 1 ? `${baseSkillId}_t${currentTier}` : baseSkillId;
- const level = skills[tieredSkillId] || skills[baseSkillId] || 0;
-
- // Tier multiplier: each tier is 10x more powerful
- const tierMultiplier = Math.pow(10, currentTier - 1);
-
- return { level, tier: currentTier, tierMultiplier };
-}
-
-export function computeMaxMana(
- state: Pick,
- effects?: ComputedEffects | UnifiedEffects
-): number {
- const pu = state.prestigeUpgrades;
- const base =
- 100 +
- (state.skills.manaWell || 0) * 100 +
- (pu.manaWell || 0) * 500;
-
- // If effects not provided, compute unified effects (includes equipment)
- if (!effects && state.equipmentInstances && state.equippedInstances) {
- effects = getUnifiedEffects(state as any);
- }
-
- // Apply effects if available (now includes equipment bonuses)
- if (effects) {
- return Math.floor((base + effects.maxManaBonus) * effects.maxManaMultiplier);
- }
- return base;
-}
-
-export function computeElementMax(
- state: Pick,
- effects?: ComputedEffects
-): number {
- const pu = state.prestigeUpgrades;
- const base = 10 + (state.skills.elemAttune || 0) * 50 + (pu.elementalAttune || 0) * 25;
-
- // Apply upgrade effects if provided
- if (effects) {
- return Math.floor((base + effects.elementCapBonus) * effects.elementCapMultiplier);
- }
- return base;
-}
-
-export function computeRegen(
- state: Pick,
- effects?: ComputedEffects | UnifiedEffects
-): number {
- const pu = state.prestigeUpgrades;
- const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1;
- const base =
- 2 +
- (state.skills.manaFlow || 0) * 1 +
- (state.skills.manaSpring || 0) * 2 +
- (pu.manaFlow || 0) * 0.5;
-
- let regen = base * temporalBonus;
-
- // If effects not provided, compute unified effects (includes equipment)
- if (!effects && state.equipmentInstances && state.equippedInstances) {
- effects = getUnifiedEffects(state as any);
- }
-
- // Apply effects if available (now includes equipment bonuses)
- if (effects) {
- regen = (regen + effects.regenBonus + effects.permanentRegenBonus) * effects.regenMultiplier;
- }
-
- return regen;
-}
-
-/**
- * Compute regen with dynamic special effects (needs current mana, max mana, incursion)
- */
-export function computeEffectiveRegen(
- state: Pick,
- effects?: ComputedEffects | UnifiedEffects
-): number {
- // Base regen from existing function
- let regen = computeRegen(state, effects);
-
- const maxMana = computeMaxMana(state, effects);
- const currentMana = state.rawMana;
- const incursionStrength = state.incursionStrength || 0;
-
- // Apply incursion penalty
- regen *= (1 - incursionStrength);
-
- return regen;
-}
-
-export function computeClickMana(
- state: Pick,
- effects?: ComputedEffects | UnifiedEffects
-): number {
- const base =
- 1 +
- (state.skills.manaTap || 0) * 1 +
- (state.skills.manaSurge || 0) * 3;
-
- // If effects not provided, compute unified effects (includes equipment)
- if (!effects && state.equipmentInstances && state.equippedInstances) {
- effects = getUnifiedEffects(state as any);
- }
-
- // Apply effects if available (now includes equipment bonuses)
- if (effects) {
- return Math.floor((base + effects.clickManaBonus) * effects.clickManaMultiplier);
- }
- return base;
-}
+// ─── Local Helper Functions ────────────────────────────────────────────────────
// Elemental damage bonus: +50% if spell element opposes floor element (super effective)
// -25% if spell element matches its own opposite (weak)
@@ -288,129 +109,6 @@ function getElementalBonus(spellElem: string, floorElem: string): number {
return 1.0; // Neutral
}
-export function calcDamage(
- state: Pick,
- spellId: string,
- floorElem?: string
-): number {
- const sp = SPELLS_DEF[spellId];
- if (!sp) return 5;
- const skills = state.skills;
- const baseDmg = sp.dmg + (skills.combatTrain || 0) * 5;
- const pct = 1 + (skills.arcaneFury || 0) * 0.1;
-
- // Elemental mastery bonus
- const elemMasteryBonus = 1 + (skills.elementalMastery || 0) * 0.15;
-
- // Guardian bane bonus
- const guardianBonus = floorElem && GUARDIANS[Object.values(GUARDIANS).find(g => g.element === floorElem)?.hp ? 0 : 0]
- ? 1 + (skills.guardianBane || 0) * 0.2
- : 1;
-
- const critChance = (skills.precision || 0) * 0.05;
- const pactMult = state.signedPacts.reduce(
- (m, f) => m * (GUARDIANS[f]?.pact || 1),
- 1
- );
-
- let damage = baseDmg * pct * pactMult * elemMasteryBonus;
-
- // Apply elemental bonus if floor element provided
- if (floorElem) {
- damage *= getElementalBonus(sp.elem, floorElem);
- }
-
- // Apply crit
- if (Math.random() < critChance) {
- damage *= 1.5;
- }
-
- return damage;
-}
-
-export function calcInsight(state: Pick): number {
- const pu = state.prestigeUpgrades;
- const skillBonus = 1 + (state.skills.insightHarvest || 0) * 0.1;
- const mult = (1 + (pu.insightAmp || 0) * 0.25) * skillBonus;
- return Math.floor(
- (state.maxFloorReached * 15 + state.totalManaGathered / 500 + state.signedPacts.length * 150) * mult
- );
-}
-
-// Meditation bonus now affects regen rate directly
-export function getMeditationBonus(meditateTicks: number, skills: Record, meditationEfficiency: number = 1): number {
- const hasMeditation = skills.meditation === 1;
- const hasDeepTrance = skills.deepTrance === 1;
- const hasVoidMeditation = skills.voidMeditation === 1;
-
- const hours = meditateTicks * HOURS_PER_TICK;
-
- // Base meditation: ramps up over 4 hours to 1.5x
- let bonus = 1 + Math.min(hours / 4, 0.5);
-
- // With Meditation Focus: up to 2.5x after 4 hours
- if (hasMeditation && hours >= 4) {
- bonus = 2.5;
- }
-
- // With Deep Trance: up to 3.0x after 6 hours
- if (hasDeepTrance && hours >= 6) {
- bonus = 3.0;
- }
-
- // With Void Meditation: up to 5.0x after 8 hours
- if (hasVoidMeditation && hours >= 8) {
- bonus = 5.0;
- }
-
- // Apply meditation efficiency from upgrades (Deep Wellspring, etc.)
- bonus *= meditationEfficiency;
-
- return bonus;
-}
-
-export function getIncursionStrength(day: number, hour: number): number {
- if (day < INCURSION_START_DAY) return 0;
- const totalHours = (day - INCURSION_START_DAY) * 24 + hour;
- const maxHours = (MAX_DAY - INCURSION_START_DAY) * 24;
- return Math.min(0.95, (totalHours / maxHours) * 0.95);
-}
-
-// Check if player can afford spell cost
-export function canAffordSpellCost(
- cost: SpellCost,
- rawMana: number,
- elements: Record
-): boolean {
- if (cost.type === 'raw') {
- return rawMana >= cost.amount;
- } else {
- const elem = elements[cost.element || ''];
- return elem && elem.unlocked && elem.current >= cost.amount;
- }
-}
-
-// Deduct spell cost from appropriate mana pool
-function deductSpellCost(
- cost: SpellCost,
- rawMana: number,
- elements: Record
-): { rawMana: number; elements: Record } {
- const newElements = { ...elements };
-
- if (cost.type === 'raw') {
- return { rawMana: rawMana - cost.amount, elements: newElements };
- } else if (cost.element && newElements[cost.element]) {
- newElements[cost.element] = {
- ...newElements[cost.element],
- current: newElements[cost.element].current - cost.amount
- };
- return { rawMana, elements: newElements };
- }
-
- return { rawMana, elements: newElements };
-}
-
// ─── Initial State Factory ────────────────────────────────────────────────────
function makeInitial(overrides: Partial = {}): GameState {
@@ -587,17 +285,12 @@ function makeInitial(overrides: Partial = {}): GameState {
// ─── Game Store ───────────────────────────────────────────────────────────────
-interface GameStore extends GameState, CraftingActions, FamiliarActions {
+interface GameStore extends GameState, CraftingActions, FamiliarActions, NavigationActions, StudyActions {
// Actions
tick: () => void;
gatherMana: () => void;
setAction: (action: GameAction) => void;
setSpell: (spellId: string) => void;
- startStudyingSkill: (skillId: string) => void;
- startStudyingSpell: (spellId: string) => void;
- startParallelStudySkill: (skillId: string) => void;
- cancelStudy: () => void;
- cancelParallelStudy: () => void;
convertMana: (element: string, amount: number) => void;
unlockElement: (element: string) => void;
craftComposite: (target: string) => void;
@@ -611,10 +304,6 @@ interface GameStore extends GameState, CraftingActions, FamiliarActions {
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
tierUpSkill: (skillId: string) => void;
- // Floor Navigation
- setClimbDirection: (direction: 'up' | 'down') => void;
- changeFloor: (direction: 'up' | 'down') => void;
-
// Inventory Management
updateLootInventory: (inventory: LootInventory) => void;
@@ -633,6 +322,8 @@ export const useGameStore = create()(
(set, get) => ({
...makeInitial(),
...createFamiliarSlice(set, get),
+ ...createNavigationSlice(set, get),
+ ...createStudySlice(set, get),
getMaxMana: () => computeMaxMana(get()),
getRegen: () => computeRegen(get()),
@@ -1221,108 +912,6 @@ export const useGameStore = create()(
}
},
- startStudyingSkill: (skillId: string) => {
- const state = get();
- const sk = SKILLS_DEF[skillId];
- if (!sk) return;
-
- const currentLevel = state.skills[skillId] || 0;
- if (currentLevel >= sk.max) return;
-
- // Check prerequisites
- if (sk.req) {
- for (const [r, rl] of Object.entries(sk.req)) {
- if ((state.skills[r] || 0) < rl) return;
- }
- }
-
- // Check mana cost (with focused mind reduction)
- const costMult = getStudyCostMultiplier(state.skills);
- const cost = Math.floor(sk.base * (currentLevel + 1) * costMult);
- if (state.rawMana < cost) return;
-
- // Start studying
- set({
- rawMana: state.rawMana - cost,
- currentAction: 'study',
- currentStudyTarget: {
- type: 'skill',
- id: skillId,
- progress: state.skillProgress[skillId] || 0,
- required: sk.studyTime,
- },
- log: [`📚 Started studying ${sk.name}...`, ...state.log.slice(0, 49)],
- });
- },
-
- startStudyingSpell: (spellId: string) => {
- const state = get();
- const sp = SPELLS_DEF[spellId];
- if (!sp || state.spells[spellId]?.learned) return;
-
- // Check mana cost (with focused mind reduction)
- const costMult = getStudyCostMultiplier(state.skills);
- const cost = Math.floor(sp.unlock * costMult);
- if (state.rawMana < cost) return;
-
- const studyTime = sp.studyTime || (sp.tier * 4); // Default study time based on tier
-
- // Start studying
- set({
- rawMana: state.rawMana - cost,
- currentAction: 'study',
- currentStudyTarget: {
- type: 'spell',
- id: spellId,
- progress: state.spells[spellId]?.studyProgress || 0,
- required: studyTime,
- },
- spells: {
- ...state.spells,
- [spellId]: { ...(state.spells[spellId] || { learned: false, level: 0 }), studyProgress: state.spells[spellId]?.studyProgress || 0 },
- },
- log: [`📚 Started studying ${sp.name}...`, ...state.log.slice(0, 49)],
- });
- },
-
- cancelStudy: () => {
- const state = get();
- if (!state.currentStudyTarget) return;
-
- // Knowledge retention bonus
- const retentionBonus = 1 + (state.skills.knowledgeRetention || 0) * 0.2;
- const savedProgress = Math.min(
- state.currentStudyTarget.progress,
- state.currentStudyTarget.required * retentionBonus
- );
-
- // Save progress
- if (state.currentStudyTarget.type === 'skill') {
- set({
- currentStudyTarget: null,
- currentAction: 'meditate',
- skillProgress: {
- ...state.skillProgress,
- [state.currentStudyTarget.id]: savedProgress,
- },
- log: [`📖 Study interrupted. Progress saved.`, ...state.log.slice(0, 49)],
- });
- } else if (state.currentStudyTarget.type === 'spell') {
- set({
- currentStudyTarget: null,
- currentAction: 'meditate',
- spells: {
- ...state.spells,
- [state.currentStudyTarget.id]: {
- ...(state.spells[state.currentStudyTarget.id] || { learned: false, level: 0 }),
- studyProgress: savedProgress,
- },
- },
- log: [`📖 Study interrupted. Progress saved.`, ...state.log.slice(0, 49)],
- });
- }
- },
-
convertMana: (element: string, amount: number = 1) => {
const state = get();
const e = state.elements[element];
@@ -1527,42 +1116,7 @@ export const useGameStore = create()(
log: [`🌟 ${SKILLS_DEF[baseSkillId]?.name || baseSkillId} evolved to Tier ${nextTier}!`, ...state.log.slice(0, 49)],
});
},
-
- startParallelStudySkill: (skillId: string) => {
- const state = get();
- if (state.parallelStudyTarget) return; // Already have parallel study
- if (!state.currentStudyTarget) return; // Need primary study
-
- const sk = SKILLS_DEF[skillId];
- if (!sk) return;
-
- const currentLevel = state.skills[skillId] || 0;
- if (currentLevel >= sk.max) return;
-
- // Can't study same thing in parallel
- if (state.currentStudyTarget.id === skillId) return;
-
- set({
- parallelStudyTarget: {
- type: 'skill',
- id: skillId,
- progress: state.skillProgress[skillId] || 0,
- required: sk.studyTime,
- },
- log: [`📚 Started parallel study of ${sk.name}... (50% speed)`, ...state.log.slice(0, 49)],
- });
- },
-
- cancelParallelStudy: () => {
- set((state) => {
- if (!state.parallelStudyTarget) return state;
- return {
- parallelStudyTarget: null,
- log: ['📖 Parallel study cancelled.', ...state.log.slice(0, 49)],
- };
- });
- },
-
+
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => {
const state = get();
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
@@ -2007,47 +1561,6 @@ export const useGameStore = create()(
};
});
},
-
- // ─── Floor Navigation ────────────────────────────────────────────────────────
-
- setClimbDirection: (direction: 'up' | 'down') => {
- set({ climbDirection: direction });
- },
-
- changeFloor: (direction: 'up' | 'down') => {
- const state = get();
- const currentFloor = state.currentFloor;
-
- // Calculate next floor
- const nextFloor = direction === 'up'
- ? Math.min(currentFloor + 1, 100)
- : Math.max(currentFloor - 1, 1);
-
- // Can't stay on same floor
- if (nextFloor === currentFloor) return;
-
- // Mark current floor as cleared (it will respawn when we come back)
- const clearedFloors = { ...state.clearedFloors };
- clearedFloors[currentFloor] = true;
-
- // Check if next floor was cleared (needs respawn)
- const nextFloorCleared = clearedFloors[nextFloor];
- if (nextFloorCleared) {
- // Respawn the floor
- delete clearedFloors[nextFloor];
- }
-
- set({
- currentFloor: nextFloor,
- floorMaxHP: getFloorMaxHP(nextFloor),
- floorHP: getFloorMaxHP(nextFloor),
- maxFloorReached: Math.max(state.maxFloorReached, nextFloor),
- clearedFloors,
- climbDirection: direction,
- equipmentSpellStates: state.equipmentSpellStates.map(s => ({ ...s, castProgress: 0 })),
- log: [`🚶 Moved to floor ${nextFloor}${nextFloorCleared ? ' (respawned)' : ''}.`, ...state.log.slice(0, 49)],
- });
- },
}),
{
name: 'mana-loop-storage',
diff --git a/src/lib/game/study-slice.ts b/src/lib/game/study-slice.ts
new file mode 100644
index 0000000..7f817e0
--- /dev/null
+++ b/src/lib/game/study-slice.ts
@@ -0,0 +1,166 @@
+// ─── Study Slice ─────────────────────────────────────────────────────────────
+// Actions for studying skills and spells
+
+import type { GameState } from './types';
+import { SKILLS_DEF, SPELLS_DEF, getStudyCostMultiplier } from './constants';
+
+// ─── Study Actions Interface ──────────────────────────────────────────────────
+
+export interface StudyActions {
+ startStudyingSkill: (skillId: string) => void;
+ startStudyingSpell: (spellId: string) => void;
+ cancelStudy: () => void;
+ startParallelStudySkill: (skillId: string) => void;
+ cancelParallelStudy: () => void;
+}
+
+// ─── Study Slice Factory ──────────────────────────────────────────────────────
+
+export function createStudySlice(
+ set: (partial: Partial | ((state: GameState) => Partial)) => void,
+ get: () => GameState
+): StudyActions {
+ return {
+ // Start studying a skill
+ startStudyingSkill: (skillId: string) => {
+ const state = get();
+ const sk = SKILLS_DEF[skillId];
+ if (!sk) return;
+
+ const currentLevel = state.skills[skillId] || 0;
+ if (currentLevel >= sk.max) return;
+
+ // Check prerequisites
+ if (sk.req) {
+ for (const [r, rl] of Object.entries(sk.req)) {
+ if ((state.skills[r] || 0) < rl) return;
+ }
+ }
+
+ // Check mana cost (with focused mind reduction)
+ const costMult = getStudyCostMultiplier(state.skills);
+ const cost = Math.floor(sk.base * (currentLevel + 1) * costMult);
+ if (state.rawMana < cost) return;
+
+ // Start studying
+ set({
+ rawMana: state.rawMana - cost,
+ currentAction: 'study',
+ currentStudyTarget: {
+ type: 'skill',
+ id: skillId,
+ progress: state.skillProgress[skillId] || 0,
+ required: sk.studyTime,
+ },
+ log: [`📚 Started studying ${sk.name}...`, ...state.log.slice(0, 49)],
+ });
+ },
+
+ // Start studying a spell
+ startStudyingSpell: (spellId: string) => {
+ const state = get();
+ const sp = SPELLS_DEF[spellId];
+ if (!sp || state.spells[spellId]?.learned) return;
+
+ // Check mana cost (with focused mind reduction)
+ const costMult = getStudyCostMultiplier(state.skills);
+ const cost = Math.floor(sp.unlock * costMult);
+ if (state.rawMana < cost) return;
+
+ const studyTime = sp.studyTime || (sp.tier * 4); // Default study time based on tier
+
+ // Start studying
+ set({
+ rawMana: state.rawMana - cost,
+ currentAction: 'study',
+ currentStudyTarget: {
+ type: 'spell',
+ id: spellId,
+ progress: state.spells[spellId]?.studyProgress || 0,
+ required: studyTime,
+ },
+ spells: {
+ ...state.spells,
+ [spellId]: { ...(state.spells[spellId] || { learned: false, level: 0 }), studyProgress: state.spells[spellId]?.studyProgress || 0 },
+ },
+ log: [`📚 Started studying ${sp.name}...`, ...state.log.slice(0, 49)],
+ });
+ },
+
+ // Cancel current study (saves progress)
+ cancelStudy: () => {
+ const state = get();
+ if (!state.currentStudyTarget) return;
+
+ // Knowledge retention bonus
+ const retentionBonus = 1 + (state.skills.knowledgeRetention || 0) * 0.2;
+ const savedProgress = Math.min(
+ state.currentStudyTarget.progress,
+ state.currentStudyTarget.required * retentionBonus
+ );
+
+ // Save progress
+ if (state.currentStudyTarget.type === 'skill') {
+ set({
+ currentStudyTarget: null,
+ currentAction: 'meditate',
+ skillProgress: {
+ ...state.skillProgress,
+ [state.currentStudyTarget.id]: savedProgress,
+ },
+ log: [`📖 Study interrupted. Progress saved.`, ...state.log.slice(0, 49)],
+ });
+ } else if (state.currentStudyTarget.type === 'spell') {
+ set({
+ currentStudyTarget: null,
+ currentAction: 'meditate',
+ spells: {
+ ...state.spells,
+ [state.currentStudyTarget.id]: {
+ ...(state.spells[state.currentStudyTarget.id] || { learned: false, level: 0 }),
+ studyProgress: savedProgress,
+ },
+ },
+ log: [`📖 Study interrupted. Progress saved.`, ...state.log.slice(0, 49)],
+ });
+ }
+ },
+
+ // Start parallel study of a skill (requires Parallel Mind upgrade)
+ startParallelStudySkill: (skillId: string) => {
+ const state = get();
+ if (state.parallelStudyTarget) return; // Already have parallel study
+ if (!state.currentStudyTarget) return; // Need primary study
+
+ const sk = SKILLS_DEF[skillId];
+ if (!sk) return;
+
+ const currentLevel = state.skills[skillId] || 0;
+ if (currentLevel >= sk.max) return;
+
+ // Can't study same thing in parallel
+ if (state.currentStudyTarget.id === skillId) return;
+
+ set({
+ parallelStudyTarget: {
+ type: 'skill',
+ id: skillId,
+ progress: state.skillProgress[skillId] || 0,
+ required: sk.studyTime,
+ },
+ log: [`📚 Started parallel study of ${sk.name}... (50% speed)`, ...state.log.slice(0, 49)],
+ });
+ },
+
+ // Cancel parallel study
+ cancelParallelStudy: () => {
+ set((state) => {
+ if (!state.parallelStudyTarget) return state;
+ return {
+ parallelStudyTarget: null,
+ log: ['📖 Parallel study cancelled.', ...state.log.slice(0, 49)],
+ };
+ });
+ },
+ };
+}
diff --git a/worklog.md b/worklog.md
index 3e110b4..fb20409 100755
--- a/worklog.md
+++ b/worklog.md
@@ -309,3 +309,134 @@ Stage Summary:
- Multi-casting is fully functional
- Game compiles and runs without errors
- Lint passes with no issues
+
+---
+## Task ID: 3 - Component Refactoring
+### Work Task
+Refactor `/home/z/my-project/src/app/page.tsx` to use existing tab components and extract new components. The file was ~2500 lines with inline render functions duplicating existing tab components.
+
+### Work Summary
+**Components Created:**
+1. `ActionButtons.tsx` - Extracted from `renderActionButtons()`, handles main action buttons (Meditate, Climb, Study, Convert) and crafting action buttons
+2. `CalendarDisplay.tsx` - Extracted from `renderCalendar()`, renders the day calendar with incursion indicators
+3. `CraftingProgress.tsx` - Extracted from `renderCraftingProgress()`, shows design/preparation/application progress bars
+4. `StudyProgress.tsx` - Extracted from `renderStudyProgress()`, displays current study progress with cancel button
+5. `ManaDisplay.tsx` - New component for mana/gathering section with progress bar and gather button
+6. `TimeDisplay.tsx` - New component for day/hour display with pause toggle
+
+**Tab Components Updated:**
+- `SpireTab.tsx` - Updated to include all functionality from inline version:
+ - Multi-spell support with activeEquipmentSpells
+ - Individual cast progress bars for each spell
+ - DPS calculation for multiple spells
+ - Parallel study support
+ - Crafting progress display
+ - ComboMeter integration
+ - Known Spells display
+ - Activity Log
+
+**File Changes:**
+- `page.tsx` reduced from ~2555 lines to 1695 lines (34% reduction)
+- Removed inline render functions: `renderCalendar`, `renderActionButtons`, `renderCraftingProgress`, `renderStudyProgress`, `renderSpireTab`, `renderSpellsTab`, `renderLabTab`
+- Updated imports to use extracted components
+- Cleaned up unused imports (`MAX_DAY`, `INCURSION_START_DAY`, `MANA_PER_ELEMENT`)
+- Created `tabs/index.ts` for cleaner tab component exports
+- Updated `game/index.ts` to export all new components
+
+**Results:**
+- All lint checks pass
+- Functionality preserved - all features working as before
+- Better code organization with reusable components
+- Easier maintenance with separated concerns
+
+---
+## Task ID: 1 - Code Extraction
+### Work Task
+Extract computed stats and utility functions from `/home/z/my-project/src/lib/game/store.ts` into a new file `/home/z/my-project/src/lib/game/computed-stats.ts`. The store.ts was ~2100 lines with functions that could be better organized in a separate module.
+
+### Work Summary
+**Created New File:** `computed-stats.ts`
+
+**Functions Extracted:**
+1. `DEFAULT_EFFECTS` constant - Default empty effects object for computed effects
+2. `fmt` and `fmtDec` - Number formatting utilities (K, M, B suffixes)
+3. `getFloorMaxHP` - Floor HP calculation with guardian and scaling logic
+4. `getFloorElement` - Floor element determination from cycle
+5. `getActiveEquipmentSpells` - Helper to get all spells from equipped caster weapons
+6. `getEffectiveSkillLevel` - Helper for tiered skill level calculation
+7. `computeMaxMana` - Maximum mana calculation with effects
+8. `computeElementMax` - Elemental mana capacity calculation
+9. `computeRegen` - Mana regeneration rate calculation
+10. `computeEffectiveRegen` - Regen with dynamic special effects
+11. `computeClickMana` - Click mana gain calculation
+12. `getElementalBonus` - Elemental damage bonus helper
+13. `calcDamage` - Damage calculation with skills, pacts, and crits
+14. `calcInsight` - End-of-loop insight calculation
+15. `getMeditationBonus` - Meditation multiplier calculation
+16. `getIncursionStrength` - Time-based incursion penalty
+17. `canAffordSpellCost` - Check if player can afford spell
+18. `deductSpellCost` - Deduct spell cost from mana pool
+
+**Files Updated:**
+- `store.ts`:
+ - Added imports from computed-stats.ts
+ - Removed duplicate function definitions
+ - Kept local `getElementalBonus` helper (used only in store)
+ - Re-exports `fmt` and `fmtDec` for backward compatibility
+- `formatting.ts`:
+ - Added re-exports of `fmt` and `fmtDec` from computed-stats.ts
+
+**Results:**
+- All lint checks pass
+- No functionality changed - pure refactoring
+- Better code organization with computed stats in dedicated module
+- Easier to test and maintain individual functions
+- Backward compatible - existing imports still work
+
+---
+## Task ID: 2 - Store Slice Refactoring
+### Work Task
+Create two new store slices to reduce the size of `/home/z/my-project/src/lib/game/store.ts`:
+1. `navigation-slice.ts` - Extract navigation-related actions
+2. `study-slice.ts` - Extract study-related actions
+
+### Work Summary
+**Created New Files:**
+
+**1. `navigation-slice.ts`** - Floor navigation actions:
+- `NavigationActions` interface defining the action types
+- `createNavigationSlice()` factory function
+- `setClimbDirection()` - Set climbing direction (up/down)
+- `changeFloor()` - Manually change floors with respawn logic
+
+**2. `study-slice.ts`** - Study system actions:
+- `StudyActions` interface defining the action types
+- `createStudySlice()` factory function
+- `startStudyingSkill()` - Begin studying a skill with prerequisite and cost checks
+- `startStudyingSpell()` - Begin studying a spell with cost checks
+- `cancelStudy()` - Cancel current study with progress retention bonus
+- `startParallelStudySkill()` - Start parallel study (requires Parallel Mind upgrade)
+- `cancelParallelStudy()` - Cancel parallel study
+
+**Files Updated:**
+- `store.ts`:
+ - Added imports for `createNavigationSlice` and `createStudySlice`
+ - Added imports for `NavigationActions` and `StudyActions` interfaces
+ - Updated `GameStore` interface to extend both new action interfaces
+ - Spread the new slices into the store
+ - Removed duplicated action implementations
+ - Added re-exports for computed stats functions (`getFloorElement`, `computeMaxMana`, `computeRegen`, `computeClickMana`, `calcDamage`, `getMeditationBonus`, `getIncursionStrength`, `canAffordSpellCost`, `getFloorMaxHP`)
+ - Added missing imports for `EQUIPMENT_TYPES` and `EquipmentInstance`
+
+**Pattern Followed:**
+- Followed existing slice patterns from `familiar-slice.ts` and `crafting-slice.ts`
+- Used factory function pattern that accepts `set` and `get` from Zustand
+- Exported both the interface and factory function
+- Proper TypeScript typing throughout
+
+**Results:**
+- All lint checks pass
+- No functionality changed - pure refactoring
+- Reduced store.ts size by extracting ~100 lines of action implementations
+- Better code organization with navigation and study logic in dedicated modules
+- Easier to maintain and extend individual features