refactor: Major codebase refactoring for maintainability
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.
This commit is contained in:
2026-03-26 12:00:30 +00:00
parent 1d2dce75cc
commit 2ca5d8b7f8
16 changed files with 1497 additions and 1527 deletions

View File

@@ -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(
<Tooltip key={d}>
<TooltipTrigger asChild>
<div className={dayClass}>
{d}
</div>
</TooltipTrigger>
<TooltipContent>
<p>Day {d}</p>
{d >= INCURSION_START_DAY && <p className="text-red-400">Incursion Active</p>}
</TooltipContent>
</Tooltip>
);
}
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 (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
{actions.map(({ id, label, icon: Icon }) => (
<Button
key={id}
variant={store.currentAction === id ? 'default' : 'outline'}
size="sm"
className={`h-9 ${store.currentAction === id ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
onClick={() => store.setAction(id)}
>
<Icon className="w-4 h-4 mr-1" />
{label}
</Button>
))}
</div>
{/* Crafting actions row - shown when there's active crafting progress */}
{(hasDesignProgress || hasPrepProgress || hasAppProgress) && (
<div className="grid grid-cols-3 gap-2">
<Button
variant={store.currentAction === 'design' ? 'default' : 'outline'}
size="sm"
disabled={!hasDesignProgress}
className={`h-9 ${store.currentAction === 'design' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
onClick={() => hasDesignProgress && store.setAction('design')}
>
<Target className="w-4 h-4 mr-1" />
Design
</Button>
<Button
variant={store.currentAction === 'prepare' ? 'default' : 'outline'}
size="sm"
disabled={!hasPrepProgress}
className={`h-9 ${store.currentAction === 'prepare' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
onClick={() => hasPrepProgress && store.setAction('prepare')}
>
<FlaskConical className="w-4 h-4 mr-1" />
Prepare
</Button>
<Button
variant={store.currentAction === 'enchant' ? 'default' : 'outline'}
size="sm"
disabled={!hasAppProgress}
className={`h-9 ${store.currentAction === 'enchant' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
onClick={() => hasAppProgress && store.setAction('enchant')}
>
<Sparkles className="w-4 h-4 mr-1" />
Enchant
</Button>
</div>
)}
</div>
);
};
// 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(
<div key="design" className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Target className="w-4 h-4 text-cyan-400" />
<span className="text-sm font-semibold text-cyan-300">
Designing Enchantment
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={() => store.cancelDesign?.()}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(store.designProgress.progress)} / {formatStudyTime(store.designProgress.required)}</span>
<span>Design Time</span>
</div>
</div>
);
}
// 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(
<div key="prepare" className="p-3 rounded border border-green-600/50 bg-green-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FlaskConical className="w-4 h-4 text-green-400" />
<span className="text-sm font-semibold text-green-300">
Preparing {instance?.name || 'Equipment'}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={() => store.cancelPreparation?.()}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(store.preparationProgress.progress)} / {formatStudyTime(store.preparationProgress.required)}</span>
<span>Mana spent: {fmt(store.preparationProgress.manaCostPaid)}</span>
</div>
</div>
);
}
// 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(
<div key="enchant" className="p-3 rounded border border-amber-600/50 bg-amber-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-400" />
<span className="text-sm font-semibold text-amber-300">
Enchanting {instance?.name || 'Equipment'}
</span>
</div>
<div className="flex gap-1">
{store.applicationProgress.paused ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-green-400 hover:text-green-300"
onClick={() => store.resumeApplication?.()}
>
<Play className="w-4 h-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-yellow-400 hover:text-yellow-300"
onClick={() => store.pauseApplication?.()}
>
<Pause className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={() => store.cancelApplication?.()}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(store.applicationProgress.progress)} / {formatStudyTime(store.applicationProgress.required)}</span>
<span>Mana/hr: {fmt(store.applicationProgress.manaPerHour)}</span>
</div>
{design && (
<div className="text-xs text-amber-400/70 mt-1">
Applying: {design.name}
</div>
)}
</div>
);
}
return progressSections.length > 0 ? (
<div className="space-y-2">
{progressSections}
</div>
) : 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 (
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-purple-400" />
<span className="text-sm font-semibold text-purple-300">
{def?.name}
{isSkill && ` Lv.${currentLevel + 1}`}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={() => store.cancelStudy()}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
<span>{studySpeedMult.toFixed(1)}x speed</span>
</div>
</div>
);
};
// 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 = () => (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Current Floor Card */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Current Floor</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold game-title" style={{ color: floorElemDef?.color }}>
{store.currentFloor}
</span>
<span className="text-gray-400 text-sm">/ 100</span>
<span className="ml-auto text-sm" style={{ color: floorElemDef?.color }}>
{floorElemDef?.sym} {floorElemDef?.name}
</span>
{isGuardianFloor && (
<Badge className="bg-red-900/50 text-red-300 border-red-600">GUARDIAN</Badge>
)}
</div>
{isGuardianFloor && currentGuardian && (
<div className="text-sm font-semibold game-panel-title" style={{ color: floorElemDef?.color }}>
{currentGuardian.name}
</div>
)}
{/* HP Bar */}
<div className="space-y-1">
<div className="h-3 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.max(0, (store.floorHP / store.floorMaxHP) * 100)}%`,
background: `linear-gradient(90deg, ${floorElemDef?.color}99, ${floorElemDef?.color})`,
boxShadow: `0 0 10px ${floorElemDef?.glow}`,
}}
/>
</div>
<div className="flex justify-between text-xs text-gray-400 game-mono">
<span>{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP</span>
<span>DPS: {store.currentAction === 'climb' && activeEquipmentSpells.length > 0 ? fmtDec(totalDPS) : '—'}</span>
</div>
</div>
<Separator className="bg-gray-700" />
{/* Floor Navigation Controls */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">Auto-Direction</span>
<div className="flex gap-1">
<Button
variant={(store.climbDirection || 'up') === 'up' ? 'default' : 'outline'}
size="sm"
className={`h-7 px-2 ${(store.climbDirection || 'up') === 'up' ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-800/50'}`}
onClick={() => store.setClimbDirection?.('up')}
>
<ChevronUp className="w-4 h-4 mr-1" />
Up
</Button>
<Button
variant={store.climbDirection === 'down' ? 'default' : 'outline'}
size="sm"
className={`h-7 px-2 ${store.climbDirection === 'down' ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-800/50'}`}
onClick={() => store.setClimbDirection?.('down')}
>
<ChevronDown className="w-4 h-4 mr-1" />
Down
</Button>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 h-8 bg-gray-800/50 hover:bg-gray-700/50 disabled:opacity-50"
disabled={store.currentFloor <= 1}
onClick={() => store.changeFloor?.('down')}
>
<ChevronDown className="w-4 h-4 mr-1" />
Descend
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 h-8 bg-gray-800/50 hover:bg-gray-700/50 disabled:opacity-50"
disabled={store.currentFloor >= 100}
onClick={() => store.changeFloor?.('up')}
>
<ChevronUp className="w-4 h-4 mr-1" />
Ascend
</Button>
</div>
{store.clearedFloors?.[store.currentFloor] && (
<div className="text-xs text-amber-400 text-center flex items-center justify-center gap-1">
<RotateCcw className="w-3 h-3" />
Floor will respawn when you leave and return
</div>
)}
</div>
<Separator className="bg-gray-700" />
<div className="text-sm text-gray-400">
Best: Floor <strong className="text-gray-200">{store.maxFloorReached}</strong>
Pacts: <strong className="text-amber-400">{store.signedPacts.length}</strong>
</div>
</CardContent>
</Card>
{/* Active Spells Card - Shows all spells from equipped weapons */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
Active Spells ({activeEquipmentSpells.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{activeEquipmentSpells.length > 0 ? (
<div className="space-y-3">
{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 (
<div key={`${spellId}-${equipmentId}`} className="p-2 rounded border border-gray-700 bg-gray-800/30">
<div className="flex items-center justify-between mb-1">
<div className="text-sm font-semibold game-panel-title" style={{ color: spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color }}>
{spellDef.name}
{spellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200 text-xs">Basic</Badge>}
{spellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100 text-xs">Legendary</Badge>}
</div>
<span className={`text-xs ${canCast ? 'text-green-400' : 'text-red-400'}`}>
{canCast ? '✓' : '✗'}
</span>
</div>
<div className="text-xs text-gray-400 game-mono mb-1">
{fmt(calcDamage(store, spellId, floorElem))} dmg
<span style={{ color: getSpellCostColor(spellDef.cost) }}>
{' '}{formatSpellCost(spellDef.cost)}
</span>
{' '} {(spellDef.castSpeed || 1).toFixed(1)}/hr
</div>
{/* Cast progress bar when climbing */}
{store.currentAction === 'climb' && (
<div className="space-y-0.5">
<div className="flex justify-between text-xs text-gray-500">
<span>Cast</span>
<span>{(progress * 100).toFixed(0)}%</span>
</div>
<Progress value={Math.min(100, progress * 100)} className="h-1.5 bg-gray-700" />
</div>
)}
{spellDef.effects && spellDef.effects.length > 0 && (
<div className="flex gap-1 flex-wrap mt-1">
{spellDef.effects.map((eff, i) => (
<Badge key={i} variant="outline" className="text-xs py-0">
{eff.type === 'lifesteal' && `🩸 ${Math.round(eff.value * 100)}%`}
{eff.type === 'burn' && `🔥 Burn`}
{eff.type === 'freeze' && `❄️ Freeze`}
</Badge>
))}
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-gray-500 text-sm">No spells on equipped weapons. Enchant a staff with spell effects.</div>
)}
{incursionStrength > 0 && (
<div className="p-2 bg-red-900/20 border border-red-800/50 rounded">
<div className="text-xs text-red-400 game-panel-title mb-1">LABYRINTH INCURSION</div>
<div className="text-sm text-gray-300">
-{Math.round(incursionStrength * 100)}% mana regen
</div>
</div>
)}
</CardContent>
</Card>
{/* Combo Meter - Shows when climbing or has active combo */}
<ComboMeter combo={store.combo} isClimbing={store.currentAction === 'climb'} />
{/* Current Study (if any) */}
{store.currentStudyTarget && (
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
<CardContent className="pt-4 space-y-3">
{renderStudyProgress()}
{/* Parallel Study Progress */}
{store.parallelStudyTarget && (
<div className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-cyan-400" />
<span className="text-sm font-semibold text-cyan-300">
Parallel: {SKILLS_DEF[store.parallelStudyTarget.id]?.name}
{store.parallelStudyTarget.type === 'skill' && ` Lv.${(store.skills[store.parallelStudyTarget.id] || 0) + 1}`}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={() => store.cancelParallelStudy()}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={Math.min(100, (store.parallelStudyTarget.progress / store.parallelStudyTarget.required) * 100)} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(store.parallelStudyTarget.progress)} / {formatStudyTime(store.parallelStudyTarget.required)}</span>
<span>50% speed (Parallel Study)</span>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Crafting Progress (if any) */}
{(store.designProgress || store.preparationProgress || store.applicationProgress) && (
<Card className="bg-gray-900/80 border-cyan-600/50 lg:col-span-2">
<CardContent className="pt-4">
{renderCraftingProgress()}
</CardContent>
</Card>
)}
{/* Spells Available */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Known Spells</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{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 (
<Button
key={id}
variant="outline"
className={`h-auto py-2 px-3 flex flex-col items-start ${isActive ? 'border-amber-500 bg-amber-900/20' : canCast ? 'border-gray-600 bg-gray-800/50 hover:bg-gray-700/50' : 'border-gray-700 bg-gray-800/30 opacity-60'}`}
onClick={() => store.setSpell(id)}
>
<div className="text-sm font-semibold" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
{def.name}
</div>
<div className="text-xs text-gray-400 game-mono">
{fmt(calcDamage(store, id))} dmg
</div>
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
{formatSpellCost(def.cost)}
</div>
</Button>
);
})}
</div>
</CardContent>
</Card>
{/* Activity Log */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Activity Log</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-32">
<div className="space-y-1">
{store.log.slice(0, 20).map((entry, i) => (
<div
key={i}
className={`text-sm ${i === 0 ? 'text-gray-200' : 'text-gray-500'} italic`}
>
{entry}
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
);
// 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 (
<div className="space-y-6">
{/* Equipment-Granted Spells */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3 text-cyan-400"> Known Spells</h3>
<p className="text-sm text-gray-400 mb-4">
Spells are obtained by enchanting equipment with spell effects.
Visit the Crafting tab to design and apply enchantments.
</p>
{equipmentSpells.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{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 (
<Card
key={id}
className={`bg-gray-900/80 border-cyan-600/50 ${canCast ? 'ring-1 ring-green-500/30' : ''}`}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
{def.name}
</CardTitle>
<Badge className="bg-cyan-900/50 text-cyan-300 text-xs">Equipment</Badge>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-xs text-gray-400">
{def.elem !== 'raw' && <span className="mr-2">{elemDef?.sym} {elemDef?.name}</span>}
<span> {def.dmg} dmg</span>
</div>
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
Cost: {formatSpellCost(def.cost)}
</div>
<div className="text-xs text-cyan-400/70">From: {source}</div>
<div className="flex gap-2">
{isActive ? (
<Badge className="bg-amber-900/50 text-amber-300">Active</Badge>
) : (
<Button size="sm" variant="outline" onClick={() => store.setSpell(id)}>
Set Active
</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
) : (
<div className="text-center p-8 bg-gray-800/30 rounded border border-gray-700">
<div className="text-gray-500 mb-2">No spells known yet</div>
<div className="text-sm text-gray-600">Enchant a staff with a spell effect to gain spells</div>
</div>
)}
</div>
{/* Pact Spells (from guardian defeats) */}
{store.signedPacts.length > 0 && (
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3 text-amber-400">🏆 Pact Spells</h3>
<p className="text-sm text-gray-400 mb-3">Spells earned through guardian pacts appear here.</p>
</div>
)}
{/* Spell Reference - show all available spells for enchanting */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3 text-purple-400">📚 Spell Reference</h3>
<p className="text-sm text-gray-400 mb-4">
These spells can be applied to equipment through the enchanting system.
Research enchantment effects in the Skills tab to unlock them for designing.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(SPELLS_DEF).map(([id, def]) => {
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
const isUnlocked = store.unlockedEffects?.includes(`spell_${id}`);
return (
<Card
key={id}
className={`bg-gray-900/80 border-gray-700 ${isUnlocked ? 'border-purple-500/50' : 'opacity-60'}`}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
{def.name}
</CardTitle>
<div className="flex gap-1">
{def.tier > 0 && <Badge variant="outline" className="text-xs">T{def.tier}</Badge>}
{isUnlocked && <Badge className="bg-purple-900/50 text-purple-300 text-xs">Unlocked</Badge>}
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-xs text-gray-400">
{def.elem !== 'raw' && <span className="mr-2">{elemDef?.sym} {elemDef?.name}</span>}
<span> {def.dmg} dmg</span>
</div>
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
Cost: {formatSpellCost(def.cost)}
</div>
{def.desc && (
<div className="text-xs text-gray-500 italic">{def.desc}</div>
)}
{!isUnlocked && (
<div className="text-xs text-amber-400/70">Research to unlock for enchanting</div>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
</div>
);
};
// Lab Tab
const renderLabTab = () => (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Elemental Mana Display */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Elemental Mana</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
{Object.entries(store.elements)
.filter(([, state]) => state.unlocked)
.map(([id, state]) => {
const def = ELEMENTS[id];
const isSelected = convertTarget === id;
return (
<div
key={id}
className={`p-2 rounded border cursor-pointer transition-all ${isSelected ? 'border-blue-500 bg-blue-900/20' : 'border-gray-700 bg-gray-800/50 hover:border-gray-600'}`}
style={{ borderColor: isSelected ? def?.color : undefined }}
onClick={() => setConvertTarget(id)}
>
<div className="text-lg text-center">{def?.sym}</div>
<div className="text-xs font-semibold text-center" style={{ color: def?.color }}>{def?.name}</div>
<div className="text-xs text-gray-400 game-mono text-center">{state.current}/{state.max}</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Element Conversion */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Element Conversion</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-400 mb-3">
Convert raw mana to elemental mana (100:1 ratio)
</p>
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onClick={() => store.convertMana(convertTarget, 1)}
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT}
>
+1 ({MANA_PER_ELEMENT})
</Button>
<Button
size="sm"
variant="outline"
onClick={() => store.convertMana(convertTarget, 10)}
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT * 10}
>
+10 ({MANA_PER_ELEMENT * 10})
</Button>
<Button
size="sm"
variant="outline"
onClick={() => store.convertMana(convertTarget, 100)}
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT * 100}
>
+100 ({MANA_PER_ELEMENT * 100})
</Button>
</div>
</CardContent>
</Card>
{/* Unlock Elements */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Unlock Elements</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-400 mb-3">
Unlock new elemental affinities (500 mana each)
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{Object.entries(store.elements)
.filter(([id, state]) => !state.unlocked && ELEMENTS[id]?.cat !== 'exotic')
.map(([id]) => {
const def = ELEMENTS[id];
return (
<div
key={id}
className="p-2 rounded border border-gray-700 bg-gray-800/50"
>
<div className="text-lg opacity-50">{def?.sym}</div>
<div className="text-xs font-semibold text-gray-500">{def?.name}</div>
<Button
size="sm"
variant="outline"
className="mt-1 w-full"
disabled={store.rawMana < 500}
onClick={() => store.unlockElement(id)}
>
Unlock
</Button>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Composite Crafting */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Composite & Exotic Crafting</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{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 (
<div
key={id}
className={`p-3 rounded border ${canCraft ? 'border-gray-600 bg-gray-800/50' : 'border-gray-700 bg-gray-800/30 opacity-50'}`}
>
<div className="flex items-center gap-2 mb-2">
<span className="text-2xl">{def.sym}</span>
<div>
<div className="text-sm font-semibold" style={{ color: def.color }}>
{def.name}
</div>
<div className="text-xs text-gray-500">{def.cat}</div>
</div>
</div>
<div className="text-xs text-gray-400 mb-2">
{recipe.map((r) => ELEMENTS[r]?.sym).join(' + ')}
</div>
<Button
size="sm"
variant={canCraft ? 'default' : 'outline'}
className="w-full"
disabled={!canCraft}
onClick={() => store.craftComposite(id)}
>
Craft
</Button>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
);
// 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' && (
<Card className="bg-gray-900/80 border-purple-600/50">
<CardContent className="pt-4">
{renderStudyProgress()}
<StudyProgress
currentStudyTarget={store.currentStudyTarget}
skills={store.skills}
studySpeedMult={studySpeedMult}
cancelStudy={store.cancelStudy}
/>
</CardContent>
</Card>
)}
@@ -2236,7 +1366,7 @@ export default function ManaLoopGame() {
{/* Calendar */}
<div className="mt-2 flex gap-1 overflow-x-auto pb-1">
{renderCalendar()}
<CalendarDisplay currentDay={store.day} />
</div>
</header>
@@ -2281,7 +1411,13 @@ export default function ManaLoopGame() {
<Card className="bg-gray-900/80 border-gray-700">
<CardContent className="pt-4">
<div className="text-xs text-amber-400 game-panel-title mb-2">Current Action</div>
{renderActionButtons()}
<ActionButtons
currentAction={store.currentAction}
designProgress={store.designProgress}
preparationProgress={store.preparationProgress}
applicationProgress={store.applicationProgress}
setAction={store.setAction}
/>
</CardContent>
</Card>
@@ -2323,15 +1459,20 @@ export default function ManaLoopGame() {
</TabsList>
<TabsContent value="spire">
{renderSpireTab()}
<SpireTab
store={store}
totalDPS={totalDPS}
studySpeedMult={studySpeedMult}
incursionStrength={incursionStrength}
/>
</TabsContent>
<TabsContent value="spells">
{renderSpellsTab()}
<SpellsTab store={store} />
</TabsContent>
<TabsContent value="lab">
{renderLabTab()}
<LabTab store={store} />
</TabsContent>
<TabsContent value="crafting">
<CraftingTab

View File

@@ -0,0 +1,87 @@
'use client';
import { Button } from '@/components/ui/button';
import { Sparkles, Swords, BookOpen, FlaskConical, Target } from 'lucide-react';
import type { GameAction } from '@/lib/game/types';
interface ActionButtonsProps {
currentAction: GameAction;
designProgress: { progress: number; required: number } | null;
preparationProgress: { progress: number; required: number } | null;
applicationProgress: { progress: number; required: number } | null;
setAction: (action: GameAction) => 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 (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
{actions.map(({ id, label, icon: Icon }) => (
<Button
key={id}
variant={currentAction === id ? 'default' : 'outline'}
size="sm"
className={`h-9 ${currentAction === id ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
onClick={() => setAction(id)}
>
<Icon className="w-4 h-4 mr-1" />
{label}
</Button>
))}
</div>
{/* Crafting actions row - shown when there's active crafting progress */}
{(hasDesignProgress || hasPrepProgress || hasAppProgress) && (
<div className="grid grid-cols-3 gap-2">
<Button
variant={currentAction === 'design' ? 'default' : 'outline'}
size="sm"
disabled={!hasDesignProgress}
className={`h-9 ${currentAction === 'design' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
onClick={() => hasDesignProgress && setAction('design')}
>
<Target className="w-4 h-4 mr-1" />
Design
</Button>
<Button
variant={currentAction === 'prepare' ? 'default' : 'outline'}
size="sm"
disabled={!hasPrepProgress}
className={`h-9 ${currentAction === 'prepare' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
onClick={() => hasPrepProgress && setAction('prepare')}
>
<FlaskConical className="w-4 h-4 mr-1" />
Prepare
</Button>
<Button
variant={currentAction === 'enchant' ? 'default' : 'outline'}
size="sm"
disabled={!hasAppProgress}
className={`h-9 ${currentAction === 'enchant' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
onClick={() => hasAppProgress && setAction('enchant')}
>
<Sparkles className="w-4 h-4 mr-1" />
Enchant
</Button>
</div>
)}
</div>
);
}

View File

@@ -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(
<Tooltip key={d}>
<TooltipTrigger asChild>
<div className={dayClass}>
{d}
</div>
</TooltipTrigger>
<TooltipContent>
<p>Day {d}</p>
{d >= INCURSION_START_DAY && <p className="text-red-400">Incursion Active</p>}
</TooltipContent>
</Tooltip>
);
}
return <>{days}</>;
}

View File

@@ -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<string, EquipmentInstance>;
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(
<div key="design" className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Target className="w-4 h-4 text-cyan-400" />
<span className="text-sm font-semibold text-cyan-300">
Designing Enchantment
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={cancelDesign}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(designProgress.progress)} / {formatStudyTime(designProgress.required)}</span>
<span>Design Time</span>
</div>
</div>
);
}
// Preparation progress
if (preparationProgress) {
const progressPct = Math.min(100, (preparationProgress.progress / preparationProgress.required) * 100);
const instance = equipmentInstances[preparationProgress.equipmentInstanceId];
progressSections.push(
<div key="prepare" className="p-3 rounded border border-green-600/50 bg-green-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FlaskConical className="w-4 h-4 text-green-400" />
<span className="text-sm font-semibold text-green-300">
Preparing {instance?.name || 'Equipment'}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={cancelPreparation}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(preparationProgress.progress)} / {formatStudyTime(preparationProgress.required)}</span>
<span>Mana spent: {fmt(preparationProgress.manaCostPaid)}</span>
</div>
</div>
);
}
// 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(
<div key="enchant" className="p-3 rounded border border-amber-600/50 bg-amber-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-400" />
<span className="text-sm font-semibold text-amber-300">
Enchanting {instance?.name || 'Equipment'}
</span>
</div>
<div className="flex gap-1">
{applicationProgress.paused ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-green-400 hover:text-green-300"
onClick={resumeApplication}
>
<Play className="w-4 h-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-yellow-400 hover:text-yellow-300"
onClick={pauseApplication}
>
<Pause className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={cancelApplication}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(applicationProgress.progress)} / {formatStudyTime(applicationProgress.required)}</span>
<span>Mana/hr: {fmt(applicationProgress.manaPerHour)}</span>
</div>
{design && (
<div className="text-xs text-amber-400/70 mt-1">
Applying: {design.name}
</div>
)}
</div>
);
}
return progressSections.length > 0 ? (
<div className="space-y-2">
{progressSections}
</div>
) : null;
}

View File

@@ -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 (
<Card className="bg-gray-900/80 border-gray-700">
<CardContent className="pt-4 space-y-3">
<div>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold game-mono text-blue-400">{fmt(rawMana)}</span>
<span className="text-sm text-gray-400">/ {fmt(maxMana)}</span>
</div>
<div className="text-xs text-gray-400">
+{fmtDec(effectiveRegen)} mana/hr {meditationMultiplier > 1.01 && <span className="text-purple-400">({fmtDec(meditationMultiplier, 1)}x med)</span>}
</div>
</div>
<Progress
value={(rawMana / maxMana) * 100}
className="h-2 bg-gray-800"
/>
<Button
className={`w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 ${isGathering ? 'animate-pulse' : ''}`}
onMouseDown={onGatherStart}
onMouseUp={onGatherEnd}
onMouseLeave={onGatherEnd}
onTouchStart={onGatherStart}
onTouchEnd={onGatherEnd}
>
<Zap className="w-4 h-4 mr-2" />
Gather +{clickMana} Mana
{isGathering && <span className="ml-2 text-xs">(Holding...)</span>}
</Button>
</CardContent>
</Card>
);
}

View File

@@ -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<string, number>;
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 (
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-purple-400" />
<span className="text-sm font-semibold text-purple-300">
{def?.name}
{isSkill && ` Lv.${currentLevel + 1}`}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={cancelStudy}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
<span>{studySpeedMult.toFixed(1)}x speed</span>
</div>
</div>
);
}

View File

@@ -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 (
<div className="flex items-center gap-4">
<div className="text-center">
<div className="text-lg font-bold game-mono text-amber-400">
Day {day}
</div>
<div className="text-xs text-gray-400">
{formatHour(hour)}
</div>
</div>
<div className="text-center">
<div className="text-lg font-bold game-mono text-purple-400">
{fmt(insight)}
</div>
<div className="text-xs text-gray-400">Insight</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={onTogglePause}
className="text-gray-400 hover:text-white"
>
{paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
</Button>
</div>
);
}

View File

@@ -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';

View File

@@ -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<string, string | null>,
equipmentInstances: Record<string, { instanceId: string; typeId: string; enchantments: Array<{ effectId: string }> }>
): 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 (
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-purple-400" />
<span className="text-sm font-semibold text-purple-300">
{def?.name}
{isSkill && ` Lv.${(store.skills[target.id] || 0) + 1}`}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={() => store.cancelStudy()}
>
</Button>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
<span>{studySpeedMult.toFixed(1)}x speed</span>
</div>
</div>
);
};
return (
<TooltipProvider>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
@@ -138,16 +134,16 @@ export function SpireTab({
</div>
<div className="flex justify-between text-xs text-gray-400 game-mono">
<span>{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP</span>
<span>DPS: {store.currentAction === 'climb' && canCastSpell(store.activeSpell) ? fmtDec(dps) : '—'}</span>
<span>DPS: {store.currentAction === 'climb' && activeEquipmentSpells.length > 0 ? fmtDec(totalDPS) : '—'}</span>
</div>
</div>
<Separator className="bg-gray-700" />
{/* Floor Navigation */}
{/* Floor Navigation Controls */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">Direction</span>
<span className="text-xs text-gray-400">Auto-Direction</span>
<div className="flex gap-1">
<Button
variant={climbDirection === 'up' ? 'default' : 'outline'}
@@ -155,7 +151,7 @@ export function SpireTab({
className={`h-7 px-2 ${climbDirection === 'up' ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-800/50'}`}
onClick={() => store.setClimbDirection('up')}
>
<ArrowUp className="w-4 h-4 mr-1" />
<ChevronUp className="w-4 h-4 mr-1" />
Up
</Button>
<Button
@@ -164,7 +160,7 @@ export function SpireTab({
className={`h-7 px-2 ${climbDirection === 'down' ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-800/50'}`}
onClick={() => store.setClimbDirection('down')}
>
<ArrowDown className="w-4 h-4 mr-1" />
<ChevronDown className="w-4 h-4 mr-1" />
Down
</Button>
</div>
@@ -179,7 +175,7 @@ export function SpireTab({
onClick={() => store.changeFloor('down')}
>
<ChevronDown className="w-4 h-4 mr-1" />
Go Down
Descend
</Button>
<Button
variant="outline"
@@ -189,13 +185,14 @@ export function SpireTab({
onClick={() => store.changeFloor('up')}
>
<ChevronUp className="w-4 h-4 mr-1" />
Go Up
Ascend
</Button>
</div>
{isFloorCleared && (
<div className="text-xs text-amber-400 text-center">
Floor will respawn when you return
<div className="text-xs text-amber-400 text-center flex items-center justify-center gap-1">
<RotateCcw className="w-3 h-3" />
Floor will respawn when you leave and return
</div>
)}
</div>
@@ -209,51 +206,74 @@ export function SpireTab({
</CardContent>
</Card>
{/* Active Spell Card */}
{/* Active Spells Card - Shows all spells from equipped weapons */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Active Spell</CardTitle>
<CardTitle className="text-amber-400 game-panel-title text-xs">
Active Spells ({activeEquipmentSpells.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{activeSpellDef ? (
<>
<div className="text-lg font-semibold game-panel-title" style={{ color: activeSpellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[activeSpellDef.elem]?.color }}>
{activeSpellDef.name}
{activeSpellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200">Basic</Badge>}
{activeSpellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100">Legendary</Badge>}
</div>
<div className="text-sm text-gray-400 game-mono">
{fmt(calcDamage(store, store.activeSpell))} dmg
<span style={{ color: getSpellCostColor(activeSpellDef.cost) }}>
{' '}{formatSpellCost(activeSpellDef.cost)}
</span>
{' '} {(activeSpellDef.castSpeed || 1).toFixed(1)} casts/hr
</div>
{activeEquipmentSpells.length > 0 ? (
<div className="space-y-3">
{activeEquipmentSpells.map(({ spellId, equipmentId }) => {
const spellDef = SPELLS_DEF[spellId];
if (!spellDef) return null;
{/* Cast progress bar when climbing */}
{store.currentAction === 'climb' && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-gray-400">
<span>Cast Progress</span>
<span>{((store.castProgress || 0) * 100).toFixed(0)}%</span>
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 (
<div key={`${spellId}-${equipmentId}`} className="p-2 rounded border border-gray-700 bg-gray-800/30">
<div className="flex items-center justify-between mb-1">
<div className="text-sm font-semibold game-panel-title" style={{ color: spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color }}>
{spellDef.name}
{spellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200 text-xs">Basic</Badge>}
{spellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100 text-xs">Legendary</Badge>}
</div>
<span className={`text-xs ${canCast ? 'text-green-400' : 'text-red-400'}`}>
{canCast ? '✓' : '✗'}
</span>
</div>
<div className="text-xs text-gray-400 game-mono mb-1">
{fmt(calcDamage(store, spellId, floorElem))} dmg
<span style={{ color: getSpellCostColor(spellDef.cost) }}>
{' '}{formatSpellCost(spellDef.cost)}
</span>
{' '} {(spellDef.castSpeed || 1).toFixed(1)}/hr
</div>
{/* Cast progress bar when climbing */}
{store.currentAction === 'climb' && (
<div className="space-y-0.5">
<div className="flex justify-between text-xs text-gray-500">
<span>Cast</span>
<span>{(progress * 100).toFixed(0)}%</span>
</div>
<Progress value={Math.min(100, progress * 100)} className="h-1.5 bg-gray-700" />
</div>
)}
{spellDef.effects && spellDef.effects.length > 0 && (
<div className="flex gap-1 flex-wrap mt-1">
{spellDef.effects.map((eff, i) => (
<Badge key={i} variant="outline" className="text-xs py-0">
{eff.type === 'lifesteal' && `🩸 ${Math.round(eff.value * 100)}%`}
{eff.type === 'burn' && `🔥 Burn`}
{eff.type === 'freeze' && `❄️ Freeze`}
</Badge>
))}
</div>
)}
</div>
<Progress value={Math.min(100, (store.castProgress || 0) * 100)} className="h-2 bg-gray-800" />
</div>
)}
{activeSpellDef.desc && (
<div className="text-xs text-gray-500 italic">{activeSpellDef.desc}</div>
)}
</>
) : (
<div className="text-gray-500">No spell selected</div>
)}
{/* Can cast indicator */}
{activeSpellDef && (
<div className={`text-xs ${canCastSpell(store.activeSpell) ? 'text-green-400' : 'text-red-400'}`}>
{canCastSpell(store.activeSpell) ? '✓ Can cast' : '✗ Insufficient mana'}
);
})}
</div>
) : (
<div className="text-gray-500 text-sm">No spells on equipped weapons. Enchant a staff with spell effects.</div>
)}
{incursionStrength > 0 && (
@@ -267,11 +287,67 @@ export function SpireTab({
</CardContent>
</Card>
{/* Combo Meter - Shows when climbing or has active combo */}
<ComboMeter combo={store.combo} isClimbing={store.currentAction === 'climb'} />
{/* Current Study (if any) */}
{store.currentStudyTarget && (
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
<CardContent className="pt-4 space-y-3">
{renderStudyProgress()}
<StudyProgress
currentStudyTarget={store.currentStudyTarget}
skills={store.skills}
studySpeedMult={studySpeedMult}
cancelStudy={store.cancelStudy}
/>
{/* Parallel Study Progress */}
{store.parallelStudyTarget && (
<div className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-cyan-400" />
<span className="text-sm font-semibold text-cyan-300">
Parallel: {SKILLS_DEF[store.parallelStudyTarget.id]?.name}
{store.parallelStudyTarget.type === 'skill' && ` Lv.${(store.skills[store.parallelStudyTarget.id] || 0) + 1}`}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={() => store.cancelParallelStudy()}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={Math.min(100, (store.parallelStudyTarget.progress / store.parallelStudyTarget.required) * 100)} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(store.parallelStudyTarget.progress)} / {formatStudyTime(store.parallelStudyTarget.required)}</span>
<span>50% speed (Parallel Study)</span>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Crafting Progress (if any) */}
{(store.designProgress || store.preparationProgress || store.applicationProgress) && (
<Card className="bg-gray-900/80 border-cyan-600/50 lg:col-span-2">
<CardContent className="pt-4">
<CraftingProgress
designProgress={store.designProgress}
preparationProgress={store.preparationProgress}
applicationProgress={store.applicationProgress}
equipmentInstances={store.equipmentInstances}
enchantmentDesigns={store.enchantmentDesigns}
cancelDesign={store.cancelDesign!}
cancelPreparation={store.cancelPreparation!}
pauseApplication={store.pauseApplication!}
resumeApplication={store.resumeApplication!}
cancelApplication={store.cancelApplication!}
/>
</CardContent>
</Card>
)}

View File

@@ -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';

View File

@@ -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<string, string | null>,
equipmentInstances: Record<string, EquipmentInstance>
): 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<string, number>,
baseSkillId: string,
skillTiers: Record<string, number> = {}
): { level: number; tier: number; tierMultiplier: number } {
// Find the highest tier the player has for this base skill
const currentTier = skillTiers[baseSkillId] || 1;
// Look for the tiered skill ID (e.g., manaFlow_t2)
const tieredSkillId = currentTier > 1 ? `${baseSkillId}_t${currentTier}` : baseSkillId;
const level = skills[tieredSkillId] || skills[baseSkillId] || 0;
// Tier multiplier: each tier is 10x more powerful
const tierMultiplier = Math.pow(10, currentTier - 1);
return { level, tier: currentTier, tierMultiplier };
}
// ─── Computed Stat Functions ────────────────────────────────────────────────────
export function computeMaxMana(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: ComputedEffects | UnifiedEffects
): number {
const pu = state.prestigeUpgrades;
const base =
100 +
(state.skills.manaWell || 0) * 100 +
(pu.manaWell || 0) * 500;
// If effects not provided, compute unified effects (includes equipment)
if (!effects && state.equipmentInstances && state.equippedInstances) {
effects = getUnifiedEffects(state as any);
}
// Apply effects if available (now includes equipment bonuses)
if (effects) {
return Math.floor((base + effects.maxManaBonus) * effects.maxManaMultiplier);
}
return base;
}
export function computeElementMax(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
effects?: ComputedEffects
): number {
const pu = state.prestigeUpgrades;
const base = 10 + (state.skills.elemAttune || 0) * 50 + (pu.elementalAttune || 0) * 25;
// Apply upgrade effects if provided
if (effects) {
return Math.floor((base + effects.elementCapBonus) * effects.elementCapMultiplier);
}
return base;
}
export function computeRegen(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
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<GameState, 'skills' | 'prestigeUpgrades' | 'rawMana' | 'incursionStrength' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
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<GameState, 'skills' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: ComputedEffects | UnifiedEffects
): number {
const base =
1 +
(state.skills.manaTap || 0) * 1 +
(state.skills.manaSurge || 0) * 3;
// If effects not provided, compute unified effects (includes equipment)
if (!effects && state.equipmentInstances && state.equippedInstances) {
effects = getUnifiedEffects(state as any);
}
// Apply effects if available (now includes equipment bonuses)
if (effects) {
return Math.floor((base + effects.clickManaBonus) * effects.clickManaMultiplier);
}
return base;
}
// ─── 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<GameState, 'skills' | 'signedPacts'>,
spellId: string,
floorElem?: string
): number {
const sp = SPELLS_DEF[spellId];
if (!sp) return 5;
const skills = state.skills;
const baseDmg = sp.dmg + (skills.combatTrain || 0) * 5;
const pct = 1 + (skills.arcaneFury || 0) * 0.1;
// Elemental mastery bonus
const elemMasteryBonus = 1 + (skills.elementalMastery || 0) * 0.15;
// Guardian bane bonus
const guardianBonus = floorElem && GUARDIANS[Object.values(GUARDIANS).find(g => g.element === floorElem)?.hp ? 0 : 0]
? 1 + (skills.guardianBane || 0) * 0.2
: 1;
const critChance = (skills.precision || 0) * 0.05;
const pactMult = state.signedPacts.reduce(
(m, f) => m * (GUARDIANS[f]?.pact || 1),
1
);
let damage = baseDmg * pct * pactMult * elemMasteryBonus;
// Apply elemental bonus if floor element provided
if (floorElem) {
damage *= getElementalBonus(sp.elem, floorElem);
}
// Apply crit
if (Math.random() < critChance) {
damage *= 1.5;
}
return damage;
}
// ─── Insight Calculation ────────────────────────────────────────────────────────
export function calcInsight(state: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'prestigeUpgrades' | 'skills'>): number {
const pu = state.prestigeUpgrades;
const skillBonus = 1 + (state.skills.insightHarvest || 0) * 0.1;
const mult = (1 + (pu.insightAmp || 0) * 0.25) * skillBonus;
return Math.floor(
(state.maxFloorReached * 15 + state.totalManaGathered / 500 + state.signedPacts.length * 150) * mult
);
}
// ─── Meditation Bonus ───────────────────────────────────────────────────────────
// Meditation bonus now affects regen rate directly
export function getMeditationBonus(meditateTicks: number, skills: Record<string, number>, meditationEfficiency: number = 1): number {
const hasMeditation = skills.meditation === 1;
const hasDeepTrance = skills.deepTrance === 1;
const hasVoidMeditation = skills.voidMeditation === 1;
const hours = meditateTicks * HOURS_PER_TICK;
// Base meditation: ramps up over 4 hours to 1.5x
let bonus = 1 + Math.min(hours / 4, 0.5);
// With Meditation Focus: up to 2.5x after 4 hours
if (hasMeditation && hours >= 4) {
bonus = 2.5;
}
// With Deep Trance: up to 3.0x after 6 hours
if (hasDeepTrance && hours >= 6) {
bonus = 3.0;
}
// With Void Meditation: up to 5.0x after 8 hours
if (hasVoidMeditation && hours >= 8) {
bonus = 5.0;
}
// Apply meditation efficiency from upgrades (Deep Wellspring, etc.)
bonus *= meditationEfficiency;
return bonus;
}
// ─── 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<string, { current: number; max: number; unlocked: boolean }>
): boolean {
if (cost.type === 'raw') {
return rawMana >= cost.amount;
} else {
const elem = elements[cost.element || ''];
return elem && elem.unlocked && elem.current >= cost.amount;
}
}
// Deduct spell cost from appropriate mana pool
export function deductSpellCost(
cost: SpellCost,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
const newElements = { ...elements };
if (cost.type === 'raw') {
return { rawMana: rawMana - cost.amount, elements: newElements };
} else if (cost.element && newElements[cost.element]) {
newElements[cost.element] = {
...newElements[cost.element],
current: newElements[cost.element].current - cost.amount
};
return { rawMana, elements: newElements };
}
return { rawMana, elements: newElements };
}

View File

@@ -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
*/

View File

@@ -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<GameState> | ((state: GameState) => Partial<GameState>)) => 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)],
});
},
};
}

View File

@@ -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<string, string | null>,
equipmentInstances: Record<string, EquipmentInstance>
): 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<string, number>,
baseSkillId: string,
skillTiers: Record<string, number> = {}
): { level: number; tier: number; tierMultiplier: number } {
// Find the highest tier the player has for this base skill
const currentTier = skillTiers[baseSkillId] || 1;
// Look for the tiered skill ID (e.g., manaFlow_t2)
const tieredSkillId = currentTier > 1 ? `${baseSkillId}_t${currentTier}` : baseSkillId;
const level = skills[tieredSkillId] || skills[baseSkillId] || 0;
// Tier multiplier: each tier is 10x more powerful
const tierMultiplier = Math.pow(10, currentTier - 1);
return { level, tier: currentTier, tierMultiplier };
}
export function computeMaxMana(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: ComputedEffects | UnifiedEffects
): number {
const pu = state.prestigeUpgrades;
const base =
100 +
(state.skills.manaWell || 0) * 100 +
(pu.manaWell || 0) * 500;
// If effects not provided, compute unified effects (includes equipment)
if (!effects && state.equipmentInstances && state.equippedInstances) {
effects = getUnifiedEffects(state as any);
}
// Apply effects if available (now includes equipment bonuses)
if (effects) {
return Math.floor((base + effects.maxManaBonus) * effects.maxManaMultiplier);
}
return base;
}
export function computeElementMax(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
effects?: ComputedEffects
): number {
const pu = state.prestigeUpgrades;
const base = 10 + (state.skills.elemAttune || 0) * 50 + (pu.elementalAttune || 0) * 25;
// Apply upgrade effects if provided
if (effects) {
return Math.floor((base + effects.elementCapBonus) * effects.elementCapMultiplier);
}
return base;
}
export function computeRegen(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
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<GameState, 'skills' | 'prestigeUpgrades' | 'rawMana' | 'incursionStrength' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
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<GameState, 'skills' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: ComputedEffects | UnifiedEffects
): number {
const base =
1 +
(state.skills.manaTap || 0) * 1 +
(state.skills.manaSurge || 0) * 3;
// If effects not provided, compute unified effects (includes equipment)
if (!effects && state.equipmentInstances && state.equippedInstances) {
effects = getUnifiedEffects(state as any);
}
// Apply effects if available (now includes equipment bonuses)
if (effects) {
return Math.floor((base + effects.clickManaBonus) * effects.clickManaMultiplier);
}
return base;
}
// ─── 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<GameState, 'skills' | 'signedPacts'>,
spellId: string,
floorElem?: string
): number {
const sp = SPELLS_DEF[spellId];
if (!sp) return 5;
const skills = state.skills;
const baseDmg = sp.dmg + (skills.combatTrain || 0) * 5;
const pct = 1 + (skills.arcaneFury || 0) * 0.1;
// Elemental mastery bonus
const elemMasteryBonus = 1 + (skills.elementalMastery || 0) * 0.15;
// Guardian bane bonus
const guardianBonus = floorElem && GUARDIANS[Object.values(GUARDIANS).find(g => g.element === floorElem)?.hp ? 0 : 0]
? 1 + (skills.guardianBane || 0) * 0.2
: 1;
const critChance = (skills.precision || 0) * 0.05;
const pactMult = state.signedPacts.reduce(
(m, f) => m * (GUARDIANS[f]?.pact || 1),
1
);
let damage = baseDmg * pct * pactMult * elemMasteryBonus;
// Apply elemental bonus if floor element provided
if (floorElem) {
damage *= getElementalBonus(sp.elem, floorElem);
}
// Apply crit
if (Math.random() < critChance) {
damage *= 1.5;
}
return damage;
}
export function calcInsight(state: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'prestigeUpgrades' | 'skills'>): number {
const pu = state.prestigeUpgrades;
const skillBonus = 1 + (state.skills.insightHarvest || 0) * 0.1;
const mult = (1 + (pu.insightAmp || 0) * 0.25) * skillBonus;
return Math.floor(
(state.maxFloorReached * 15 + state.totalManaGathered / 500 + state.signedPacts.length * 150) * mult
);
}
// Meditation bonus now affects regen rate directly
export function getMeditationBonus(meditateTicks: number, skills: Record<string, number>, meditationEfficiency: number = 1): number {
const hasMeditation = skills.meditation === 1;
const hasDeepTrance = skills.deepTrance === 1;
const hasVoidMeditation = skills.voidMeditation === 1;
const hours = meditateTicks * HOURS_PER_TICK;
// Base meditation: ramps up over 4 hours to 1.5x
let bonus = 1 + Math.min(hours / 4, 0.5);
// With Meditation Focus: up to 2.5x after 4 hours
if (hasMeditation && hours >= 4) {
bonus = 2.5;
}
// With Deep Trance: up to 3.0x after 6 hours
if (hasDeepTrance && hours >= 6) {
bonus = 3.0;
}
// With Void Meditation: up to 5.0x after 8 hours
if (hasVoidMeditation && hours >= 8) {
bonus = 5.0;
}
// Apply meditation efficiency from upgrades (Deep Wellspring, etc.)
bonus *= meditationEfficiency;
return bonus;
}
export function getIncursionStrength(day: number, hour: number): number {
if (day < INCURSION_START_DAY) return 0;
const totalHours = (day - INCURSION_START_DAY) * 24 + hour;
const maxHours = (MAX_DAY - INCURSION_START_DAY) * 24;
return Math.min(0.95, (totalHours / maxHours) * 0.95);
}
// Check if player can afford spell cost
export function canAffordSpellCost(
cost: SpellCost,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>
): boolean {
if (cost.type === 'raw') {
return rawMana >= cost.amount;
} else {
const elem = elements[cost.element || ''];
return elem && elem.unlocked && elem.current >= cost.amount;
}
}
// Deduct spell cost from appropriate mana pool
function deductSpellCost(
cost: SpellCost,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
const newElements = { ...elements };
if (cost.type === 'raw') {
return { rawMana: rawMana - cost.amount, elements: newElements };
} else if (cost.element && newElements[cost.element]) {
newElements[cost.element] = {
...newElements[cost.element],
current: newElements[cost.element].current - cost.amount
};
return { rawMana, elements: newElements };
}
return { rawMana, elements: newElements };
}
// ─── Initial State Factory ────────────────────────────────────────────────────
function makeInitial(overrides: Partial<GameState> = {}): GameState {
@@ -587,17 +285,12 @@ function makeInitial(overrides: Partial<GameState> = {}): 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<GameStore>()(
(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<GameStore>()(
}
},
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];
@@ -1528,41 +1117,6 @@ export const useGameStore = create<GameStore>()(
});
},
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<GameStore>()(
};
});
},
// ─── 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',

166
src/lib/game/study-slice.ts Normal file
View File

@@ -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<GameState> | ((state: GameState) => Partial<GameState>)) => 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)],
};
});
},
};
}

View File

@@ -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