Compare commits

...

3 Commits

Author SHA1 Message Date
2ca5d8b7f8 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.
2026-03-26 12:00:30 +00:00
1d2dce75cc Add equipment crafting system
- Add crafting-recipes.ts with blueprint definitions and material requirements
- Update crafting-slice.ts with equipment crafting functions
- Add EquipmentCraftingProgress type and state
- Update CraftingTab.tsx with new Craft tab for equipment crafting
- Add material deletion functionality
- Update store.ts with equipment crafting methods
- Update page.tsx to pass new props to CraftingTab

Features:
- Players can craft equipment from discovered blueprints
- Crafting requires materials and mana
- Materials are obtained from loot drops
- New Craft tab in the crafting interface
- Shows blueprint details and material requirements
2026-03-26 11:04:50 +00:00
4f4cbeb527 Add floor navigation system with up/down direction and respawn mechanics
- Added climbDirection state to track player movement direction
- Added clearedFloors tracking for floor respawn system
- Players can now manually navigate between floors using ascend/descend buttons
- Floors respawn when player leaves and returns (for loot farming)
- Enhanced LootInventory component with:
  - Full inventory management (materials, essence, equipment)
  - Search and filter functionality
  - Sorting by name, rarity, or count
  - Delete functionality with confirmation dialog
- Added updateLootInventory function to store
- Blueprints are now shown as permanent unlocks in inventory
- Floor navigation UI shows direction toggle and respawn indicators
2026-03-26 10:28:15 +00:00
21 changed files with 2796 additions and 1512 deletions

View File

@@ -2,9 +2,10 @@
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';
import { SKILL_EVOLUTION_PATHS, getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '@/lib/game/skill-evolution';
import { getUnifiedEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
import type { SkillUpgradeChoice } from '@/lib/game/types';
@@ -18,11 +19,11 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
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 } from 'lucide-react';
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';
@@ -236,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];
@@ -516,551 +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" />
<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 => {
@@ -1219,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>
)}
@@ -2176,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>
@@ -2221,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>
@@ -2263,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
@@ -2281,10 +1482,12 @@ export default function ManaLoopGame() {
designProgress={store.designProgress}
preparationProgress={store.preparationProgress}
applicationProgress={store.applicationProgress}
equipmentCraftingProgress={store.equipmentCraftingProgress}
rawMana={store.rawMana}
skills={store.skills}
currentAction={store.currentAction}
unlockedEffects={store.unlockedEffects || ['spell_manaBolt']}
lootInventory={store.lootInventory}
startDesigningEnchantment={store.startDesigningEnchantment}
cancelDesign={store.cancelDesign}
saveDesign={store.saveDesign}
@@ -2297,6 +1500,9 @@ export default function ManaLoopGame() {
cancelApplication={store.cancelApplication}
disenchantEquipment={store.disenchantEquipment}
getAvailableCapacity={store.getAvailableCapacity}
startCraftingEquipment={store.startCraftingEquipment}
cancelEquipmentCrafting={store.cancelEquipmentCrafting}
deleteMaterial={store.deleteMaterial}
/>
</TabsContent>
@@ -2310,7 +1516,25 @@ export default function ManaLoopGame() {
<TabsContent value="loot">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<LootInventoryDisplay inventory={store.lootInventory} />
<LootInventoryDisplay
inventory={store.lootInventory}
elements={store.elements}
equipmentInstances={store.equipmentInstances}
onDeleteMaterial={(id, amount) => {
// Remove material from inventory
const newMaterials = { ...store.lootInventory.materials };
delete newMaterials[id];
store.updateLootInventory?.({
...store.lootInventory,
materials: newMaterials,
});
store.addLog?.(`🗑️ Deleted ${LOOT_DROPS[id]?.name || id} (${amount})`);
}}
onDeleteEquipment={(instanceId) => {
store.deleteEquipmentInstance?.(instanceId);
store.addLog?.(`🗑️ Deleted equipment`);
}}
/>
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">

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

@@ -1,117 +1,460 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Gem, Sparkles, Scroll, Droplet } from 'lucide-react';
import type { LootInventory as LootInventoryType } from '@/lib/game/types';
import { Separator } from '@/components/ui/separator';
import { Input } from '@/components/ui/input';
import {
Gem, Sparkles, Scroll, Droplet, Trash2, Search,
Package, Sword, Shield, Shirt, Crown, ArrowUpDown,
Wrench, AlertTriangle
} from 'lucide-react';
import type { LootInventory as LootInventoryType, EquipmentInstance, ElementState } from '@/lib/game/types';
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
import { ELEMENTS } from '@/lib/game/constants';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface LootInventoryProps {
inventory: LootInventoryType;
elements?: Record<string, ElementState>;
equipmentInstances?: Record<string, EquipmentInstance>;
onDeleteMaterial?: (materialId: string, amount: number) => void;
onDeleteEquipment?: (instanceId: string) => void;
}
export function LootInventoryDisplay({ inventory }: LootInventoryProps) {
type SortMode = 'name' | 'rarity' | 'count';
type FilterMode = 'all' | 'materials' | 'essence' | 'blueprints' | 'equipment';
const RARITY_ORDER = {
common: 0,
uncommon: 1,
rare: 2,
epic: 3,
legendary: 4,
mythic: 5,
};
const CATEGORY_ICONS: Record<string, typeof Sword> = {
caster: Sword,
shield: Shield,
catalyst: Sparkles,
head: Crown,
body: Shirt,
hands: Wrench,
feet: Package,
accessory: Gem,
};
export function LootInventoryDisplay({
inventory,
elements,
equipmentInstances = {},
onDeleteMaterial,
onDeleteEquipment,
}: LootInventoryProps) {
const [searchTerm, setSearchTerm] = useState('');
const [sortMode, setSortMode] = useState<SortMode>('rarity');
const [filterMode, setFilterMode] = useState<FilterMode>('all');
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'material' | 'equipment'; id: string; name: string } | null>(null);
// Count items
const materialCount = Object.values(inventory.materials).reduce((a, b) => a + b, 0);
const essenceCount = elements ? Object.values(elements).reduce((a, e) => a + e.current, 0) : 0;
const blueprintCount = inventory.blueprints.length;
if (materialCount === 0 && blueprintCount === 0) {
const equipmentCount = Object.keys(equipmentInstances).length;
const totalItems = materialCount + blueprintCount + equipmentCount;
// Filter and sort materials
const filteredMaterials = Object.entries(inventory.materials)
.filter(([id, count]) => {
if (count <= 0) return false;
const drop = LOOT_DROPS[id];
if (!drop) return false;
if (searchTerm && !drop.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
return true;
})
.sort(([aId, aCount], [bId, bCount]) => {
const aDrop = LOOT_DROPS[aId];
const bDrop = LOOT_DROPS[bId];
if (!aDrop || !bDrop) return 0;
switch (sortMode) {
case 'name':
return aDrop.name.localeCompare(bDrop.name);
case 'rarity':
return RARITY_ORDER[bDrop.rarity] - RARITY_ORDER[aDrop.rarity];
case 'count':
return bCount - aCount;
default:
return 0;
}
});
// Filter and sort essence
const filteredEssence = elements
? Object.entries(elements)
.filter(([id, state]) => {
if (!state.unlocked || state.current <= 0) return false;
if (searchTerm && !ELEMENTS[id]?.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
return true;
})
.sort(([aId, aState], [bId, bState]) => {
switch (sortMode) {
case 'name':
return (ELEMENTS[aId]?.name || aId).localeCompare(ELEMENTS[bId]?.name || bId);
case 'count':
return bState.current - aState.current;
default:
return 0;
}
})
: [];
// Filter and sort equipment
const filteredEquipment = Object.entries(equipmentInstances)
.filter(([id, instance]) => {
if (searchTerm && !instance.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
return true;
})
.sort(([aId, aInst], [bId, bInst]) => {
switch (sortMode) {
case 'name':
return aInst.name.localeCompare(bInst.name);
case 'rarity':
return RARITY_ORDER[bInst.rarity] - RARITY_ORDER[aInst.rarity];
default:
return 0;
}
});
// Check if we have anything to show
const hasItems = totalItems > 0 || essenceCount > 0;
if (!hasItems) {
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
<Gem className="w-4 h-4" />
Loot Inventory
Inventory
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-gray-500 text-sm text-center py-4">
No loot collected yet. Defeat floors and guardians to find items!
No items collected yet. Defeat floors and guardians to find loot!
</div>
</CardContent>
</Card>
);
}
const handleDeleteMaterial = (materialId: string) => {
const drop = LOOT_DROPS[materialId];
if (drop) {
setDeleteConfirm({ type: 'material', id: materialId, name: drop.name });
}
};
const handleDeleteEquipment = (instanceId: string) => {
const instance = equipmentInstances[instanceId];
if (instance) {
setDeleteConfirm({ type: 'equipment', id: instanceId, name: instance.name });
}
};
const confirmDelete = () => {
if (!deleteConfirm) return;
if (deleteConfirm.type === 'material' && onDeleteMaterial) {
onDeleteMaterial(deleteConfirm.id, inventory.materials[deleteConfirm.id] || 0);
} else if (deleteConfirm.type === 'equipment' && onDeleteEquipment) {
onDeleteEquipment(deleteConfirm.id);
}
setDeleteConfirm(null);
};
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
<Gem className="w-4 h-4" />
Loot Inventory
<Badge className="ml-auto bg-gray-800 text-gray-300">
{materialCount + blueprintCount} items
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-48">
<div className="space-y-3">
{/* Materials */}
{Object.entries(inventory.materials).length > 0 && (
<div>
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
<Sparkles className="w-3 h-3" />
Materials
</div>
<div className="grid grid-cols-2 gap-2">
{Object.entries(inventory.materials).map(([id, count]) => {
const drop = LOOT_DROPS[id];
if (!drop || count <= 0) return null;
const rarityStyle = RARITY_COLORS[drop.rarity];
return (
<div
key={id}
className="p-2 rounded border bg-gray-800/50"
style={{
borderColor: rarityStyle?.color || '#9CA3AF',
}}
>
<div className="text-xs font-semibold" style={{ color: rarityStyle?.color }}>
{drop.name}
</div>
<div className="text-xs text-gray-400">
x{count}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Blueprints */}
{inventory.blueprints.length > 0 && (
<div>
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
<Scroll className="w-3 h-3" />
Blueprints Discovered
</div>
<div className="flex flex-wrap gap-1">
{inventory.blueprints.map((id) => {
const drop = LOOT_DROPS[id];
if (!drop) return null;
const rarityStyle = RARITY_COLORS[drop.rarity];
return (
<Badge
key={id}
className="text-xs"
style={{
backgroundColor: `${rarityStyle?.color}20`,
color: rarityStyle?.color,
borderColor: rarityStyle?.color,
}}
>
{drop.name}
</Badge>
);
})}
</div>
</div>
)}
<>
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
<Gem className="w-4 h-4" />
Inventory
<Badge className="ml-auto bg-gray-800 text-gray-300 text-xs">
{totalItems} items
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* Search and Filter Controls */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-500" />
<Input
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-7 pl-7 bg-gray-800/50 border-gray-700 text-xs"
/>
</div>
<Button
variant="outline"
size="sm"
className="h-7 px-2 bg-gray-800/50"
onClick={() => setSortMode(sortMode === 'rarity' ? 'name' : sortMode === 'name' ? 'count' : 'rarity')}
>
<ArrowUpDown className="w-3 h-3" />
</Button>
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Filter Tabs */}
<div className="flex gap-1 flex-wrap">
{[
{ mode: 'all' as FilterMode, label: 'All' },
{ mode: 'materials' as FilterMode, label: `Materials (${materialCount})` },
{ mode: 'essence' as FilterMode, label: `Essence (${essenceCount})` },
{ mode: 'blueprints' as FilterMode, label: `Blueprints (${blueprintCount})` },
{ mode: 'equipment' as FilterMode, label: `Equipment (${equipmentCount})` },
].map(({ mode, label }) => (
<Button
key={mode}
variant={filterMode === mode ? 'default' : 'outline'}
size="sm"
className={`h-6 px-2 text-xs ${filterMode === mode ? 'bg-amber-600 hover:bg-amber-700' : 'bg-gray-800/50'}`}
onClick={() => setFilterMode(mode)}
>
{label}
</Button>
))}
</div>
<Separator className="bg-gray-700" />
<ScrollArea className="h-64">
<div className="space-y-3">
{/* Materials */}
{(filterMode === 'all' || filterMode === 'materials') && filteredMaterials.length > 0 && (
<div>
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
<Sparkles className="w-3 h-3" />
Materials
</div>
<div className="grid grid-cols-2 gap-2">
{filteredMaterials.map(([id, count]) => {
const drop = LOOT_DROPS[id];
if (!drop) return null;
const rarityStyle = RARITY_COLORS[drop.rarity];
return (
<div
key={id}
className="p-2 rounded border bg-gray-800/50 group relative"
style={{
borderColor: rarityStyle?.color || '#9CA3AF',
}}
>
<div className="flex items-start justify-between">
<div>
<div className="text-xs font-semibold" style={{ color: rarityStyle?.color }}>
{drop.name}
</div>
<div className="text-xs text-gray-400">
x{count}
</div>
<div className="text-xs text-gray-500 capitalize">
{drop.rarity}
</div>
</div>
{onDeleteMaterial && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => handleDeleteMaterial(id)}
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Essence */}
{(filterMode === 'all' || filterMode === 'essence') && filteredEssence.length > 0 && (
<div>
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
<Droplet className="w-3 h-3" />
Elemental Essence
</div>
<div className="grid grid-cols-2 gap-2">
{filteredEssence.map(([id, state]) => {
const elem = ELEMENTS[id];
if (!elem) return null;
return (
<div
key={id}
className="p-2 rounded border bg-gray-800/50"
style={{
borderColor: elem.color,
}}
>
<div className="flex items-center gap-1">
<span style={{ color: elem.color }}>{elem.sym}</span>
<span className="text-xs font-semibold" style={{ color: elem.color }}>
{elem.name}
</span>
</div>
<div className="text-xs text-gray-400">
{state.current} / {state.max}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Blueprints */}
{(filterMode === 'all' || filterMode === 'blueprints') && inventory.blueprints.length > 0 && (
<div>
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
<Scroll className="w-3 h-3" />
Blueprints (permanent)
</div>
<div className="flex flex-wrap gap-1">
{inventory.blueprints.map((id) => {
const drop = LOOT_DROPS[id];
if (!drop) return null;
const rarityStyle = RARITY_COLORS[drop.rarity];
return (
<Badge
key={id}
className="text-xs"
style={{
backgroundColor: `${rarityStyle?.color}20`,
color: rarityStyle?.color,
borderColor: rarityStyle?.color,
}}
>
{drop.name}
</Badge>
);
})}
</div>
<div className="text-xs text-gray-500 mt-1 italic">
Blueprints are permanent unlocks - use them to craft equipment
</div>
</div>
)}
{/* Equipment */}
{(filterMode === 'all' || filterMode === 'equipment') && filteredEquipment.length > 0 && (
<div>
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
<Package className="w-3 h-3" />
Equipment
</div>
<div className="space-y-2">
{filteredEquipment.map(([id, instance]) => {
const type = EQUIPMENT_TYPES[instance.typeId];
const Icon = type ? CATEGORY_ICONS[type.category] || Package : Package;
const rarityStyle = RARITY_COLORS[instance.rarity];
return (
<div
key={id}
className="p-2 rounded border bg-gray-800/50 group"
style={{
borderColor: rarityStyle?.color || '#9CA3AF',
}}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-2">
<Icon className="w-4 h-4 mt-0.5" style={{ color: rarityStyle?.color }} />
<div>
<div className="text-xs font-semibold" style={{ color: rarityStyle?.color }}>
{instance.name}
</div>
<div className="text-xs text-gray-400">
{type?.name} {instance.usedCapacity}/{instance.totalCapacity} cap
</div>
<div className="text-xs text-gray-500 capitalize">
{instance.rarity} {instance.enchantments.length} enchants
</div>
</div>
</div>
{onDeleteEquipment && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => handleDeleteEquipment(id)}
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteConfirm} onOpenChange={() => setDeleteConfirm(null)}>
<AlertDialogContent className="bg-gray-900 border-gray-700">
<AlertDialogHeader>
<AlertDialogTitle className="text-amber-400 flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Delete Item
</AlertDialogTitle>
<AlertDialogDescription className="text-gray-300">
Are you sure you want to delete <strong>{deleteConfirm?.name}</strong>?
{deleteConfirm?.type === 'material' && (
<span className="block mt-2 text-red-400">
This will delete ALL {inventory.materials[deleteConfirm?.id || ''] || 0} of this material!
</span>
)}
{deleteConfirm?.type === 'equipment' && (
<span className="block mt-2 text-red-400">
This equipment and all its enchantments will be permanently lost!
</span>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="bg-gray-800 border-gray-700">Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={confirmDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

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

@@ -10,11 +10,13 @@ import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Wand2, Scroll, Hammer, Sparkles, Trash2, Plus, Minus,
Package, Zap, Clock, ChevronRight, Circle
Package, Zap, Clock, ChevronRight, Circle, Anvil
} from 'lucide-react';
import { EQUIPMENT_TYPES, type EquipmentType, type EquipmentSlot } from '@/lib/game/data/equipment';
import { ENCHANTMENT_EFFECTS, type EnchantmentEffectDef, calculateEffectCapacityCost } from '@/lib/game/data/enchantment-effects';
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, AppliedEnchantment } from '@/lib/game/types';
import { CRAFTING_RECIPES, canCraftRecipe } from '@/lib/game/data/crafting-recipes';
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, AppliedEnchantment, LootInventory, EquipmentCraftingProgress } from '@/lib/game/types';
import { fmt } from '@/lib/game/store';
// Slot display names
@@ -39,6 +41,7 @@ interface CraftingTabProps {
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; manaSpent: number } | null;
equipmentCraftingProgress: EquipmentCraftingProgress | null;
// Player state
rawMana: number;
@@ -46,6 +49,9 @@ interface CraftingTabProps {
currentAction: string;
unlockedEffects: string[]; // Effect IDs that have been researched
// Loot inventory
lootInventory: LootInventory;
// Actions
startDesigningEnchantment: (name: string, equipmentTypeId: string, effects: DesignEffect[]) => boolean;
cancelDesign: () => void;
@@ -59,6 +65,11 @@ interface CraftingTabProps {
cancelApplication: () => void;
disenchantEquipment: (instanceId: string) => void;
getAvailableCapacity: (instanceId: string) => number;
// Equipment crafting actions
startCraftingEquipment: (blueprintId: string) => boolean;
cancelEquipmentCrafting: () => void;
deleteMaterial: (materialId: string, amount: number) => void;
}
export function CraftingTab({
@@ -68,10 +79,12 @@ export function CraftingTab({
designProgress,
preparationProgress,
applicationProgress,
equipmentCraftingProgress,
rawMana,
skills,
currentAction,
unlockedEffects,
lootInventory,
startDesigningEnchantment,
cancelDesign,
saveDesign,
@@ -84,8 +97,11 @@ export function CraftingTab({
cancelApplication,
disenchantEquipment,
getAvailableCapacity,
startCraftingEquipment,
cancelEquipmentCrafting,
deleteMaterial,
}: CraftingTabProps) {
const [craftingStage, setCraftingStage] = useState<'design' | 'prepare' | 'apply'>('design');
const [craftingStage, setCraftingStage] = useState<'design' | 'prepare' | 'apply' | 'craft'>('craft');
const [selectedEquipmentType, setSelectedEquipmentType] = useState<string | null>(null);
const [selectedEquipmentInstance, setSelectedEquipmentInstance] = useState<string | null>(null);
const [selectedDesign, setSelectedDesign] = useState<string | null>(null);
@@ -718,11 +734,189 @@ export function CraftingTab({
</div>
);
// Render equipment crafting stage
const renderCraftStage = () => (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Blueprint Selection */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
<Anvil className="w-4 h-4" />
Available Blueprints
</CardTitle>
</CardHeader>
<CardContent>
{equipmentCraftingProgress ? (
<div className="space-y-3">
<div className="text-sm text-gray-400">
Crafting: {CRAFTING_RECIPES[equipmentCraftingProgress.blueprintId]?.name}
</div>
<Progress value={(equipmentCraftingProgress.progress / equipmentCraftingProgress.required) * 100} className="h-3" />
<div className="flex justify-between text-xs text-gray-400">
<span>{equipmentCraftingProgress.progress.toFixed(1)}h / {equipmentCraftingProgress.required.toFixed(1)}h</span>
<span>Mana spent: {fmt(equipmentCraftingProgress.manaSpent)}</span>
</div>
<Button size="sm" variant="outline" onClick={cancelEquipmentCrafting}>Cancel</Button>
</div>
) : (
<ScrollArea className="h-64">
<div className="space-y-2">
{lootInventory.blueprints.length === 0 ? (
<div className="text-center text-gray-400 py-4">
<Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>No blueprints discovered yet.</p>
<p className="text-xs mt-1">Defeat guardians to find blueprints!</p>
</div>
) : (
lootInventory.blueprints.map(bpId => {
const recipe = CRAFTING_RECIPES[bpId];
if (!recipe) return null;
const { canCraft, missingMaterials, missingMana } = canCraftRecipe(
recipe,
lootInventory.materials,
rawMana
);
const rarityStyle = RARITY_COLORS[recipe.rarity];
return (
<div
key={bpId}
className="p-3 rounded border bg-gray-800/50"
style={{ borderColor: rarityStyle?.color }}
>
<div className="flex justify-between items-start mb-2">
<div>
<div className="font-semibold" style={{ color: rarityStyle?.color }}>
{recipe.name}
</div>
<div className="text-xs text-gray-400 capitalize">{recipe.rarity}</div>
</div>
<Badge variant="outline" className="text-xs">
{EQUIPMENT_TYPES[recipe.equipmentTypeId]?.category}
</Badge>
</div>
<div className="text-xs text-gray-400 mb-2">{recipe.description}</div>
<Separator className="bg-gray-700 my-2" />
<div className="text-xs space-y-1">
<div className="text-gray-500">Materials:</div>
{Object.entries(recipe.materials).map(([matId, amount]) => {
const available = lootInventory.materials[matId] || 0;
const matDrop = LOOT_DROPS[matId];
const hasEnough = available >= amount;
return (
<div key={matId} className="flex justify-between">
<span>{matDrop?.name || matId}</span>
<span className={hasEnough ? 'text-green-400' : 'text-red-400'}>
{available} / {amount}
</span>
</div>
);
})}
<div className="flex justify-between mt-2">
<span>Mana Cost:</span>
<span className={rawMana >= recipe.manaCost ? 'text-green-400' : 'text-red-400'}>
{fmt(recipe.manaCost)}
</span>
</div>
<div className="flex justify-between">
<span>Craft Time:</span>
<span>{recipe.craftTime}h</span>
</div>
</div>
<Button
className="w-full mt-3"
size="sm"
disabled={!canCraft || currentAction === 'craft'}
onClick={() => startCraftingEquipment(bpId)}
>
{canCraft ? 'Craft Equipment' : 'Missing Resources'}
</Button>
</div>
);
})
)}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
{/* Materials Inventory */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
<Package className="w-4 h-4" />
Materials ({Object.values(lootInventory.materials).reduce((a, b) => a + b, 0)})
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-64">
{Object.keys(lootInventory.materials).length === 0 ? (
<div className="text-center text-gray-400 py-4">
<Sparkles className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>No materials collected yet.</p>
<p className="text-xs mt-1">Defeat floors to gather materials!</p>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
{Object.entries(lootInventory.materials).map(([matId, count]) => {
if (count <= 0) return null;
const drop = LOOT_DROPS[matId];
if (!drop) return null;
const rarityStyle = RARITY_COLORS[drop.rarity];
return (
<div
key={matId}
className="p-2 rounded border bg-gray-800/50 group relative"
style={{ borderColor: rarityStyle?.color }}
>
<div className="flex items-start justify-between">
<div>
<div className="text-sm font-semibold" style={{ color: rarityStyle?.color }}>
{drop.name}
</div>
<div className="text-xs text-gray-400">x{count}</div>
</div>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => deleteMaterial(matId, count)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
);
})}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
</div>
);
return (
<div className="space-y-4">
{/* Stage Tabs */}
<Tabs value={craftingStage} onValueChange={(v) => setCraftingStage(v as typeof craftingStage)}>
<TabsList className="bg-gray-800/50">
<TabsTrigger value="craft" className="data-[state=active]:bg-cyan-600">
<Anvil className="w-4 h-4 mr-1" />
Craft
</TabsTrigger>
<TabsTrigger value="design" className="data-[state=active]:bg-amber-600">
<Scroll className="w-4 h-4 mr-1" />
Design
@@ -737,6 +931,9 @@ export function CraftingTab({
</TabsTrigger>
</TabsList>
<TabsContent value="craft" className="mt-4">
{renderCraftStage()}
</TabsContent>
<TabsContent value="design" className="mt-4">
{renderDesignStage()}
</TabsContent>
@@ -749,6 +946,20 @@ export function CraftingTab({
</Tabs>
{/* Current Activity Indicator */}
{currentAction === 'craft' && equipmentCraftingProgress && (
<Card className="bg-cyan-900/30 border-cyan-600">
<CardContent className="py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Anvil className="w-5 h-5 text-cyan-400" />
<span>Crafting equipment...</span>
</div>
<div className="text-sm text-gray-400">
{((equipmentCraftingProgress.progress / equipmentCraftingProgress.required) * 100).toFixed(0)}%
</div>
</CardContent>
</Card>
)}
{currentAction === 'design' && designProgress && (
<Card className="bg-purple-900/30 border-purple-600">
<CardContent className="py-3 flex items-center justify-between">

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 } 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 & {
@@ -20,31 +54,37 @@ interface SpireTabProps {
setSpell: (spellId: string) => void;
cancelStudy: () => void;
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];
@@ -52,43 +92,6 @@ export function SpireTab({
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">
@@ -131,12 +134,71 @@ 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 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={climbDirection === 'up' ? 'default' : 'outline'}
size="sm"
className={`h-7 px-2 ${climbDirection === '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={climbDirection === 'down' ? 'default' : 'outline'}
size="sm"
className={`h-7 px-2 ${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>
{isFloorCleared && (
<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>
@@ -144,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>
{/* 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>
{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>
<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 && (
@@ -202,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

@@ -1,9 +1,10 @@
// ─── Crafting Store Slice ─────────────────────────────────────────────────────────
// Handles equipment and enchantment system: design, prepare, apply stages
import type { GameState, EquipmentInstance, AppliedEnchantment, EnchantmentDesign, DesignEffect, EquipmentSlot } from './types';
import type { GameState, EquipmentInstance, AppliedEnchantment, EnchantmentDesign, DesignEffect, EquipmentSlot, EquipmentCraftingProgress, LootInventory } from './types';
import { EQUIPMENT_TYPES, type EquipmentCategory } from './data/equipment';
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
import { CRAFTING_RECIPES, canCraftRecipe, type CraftingRecipe } from './data/crafting-recipes';
import { SPELLS_DEF } from './constants';
// ─── Helper Functions ─────────────────────────────────────────────────────────
@@ -88,6 +89,11 @@ export interface CraftingActions {
// Disenchanting
disenchantEquipment: (instanceId: string) => void;
// Equipment Crafting (from blueprints)
startCraftingEquipment: (blueprintId: string) => boolean;
cancelEquipmentCrafting: () => void;
deleteMaterial: (materialId: string, amount: number) => void;
// Computed getters
getEquipmentSpells: () => string[];
getEquipmentEffects: () => Record<string, number>;
@@ -502,6 +508,97 @@ export function createCraftingSlice(
if (!instance) return 0;
return instance.totalCapacity - instance.usedCapacity;
},
// ─── Equipment Crafting (from Blueprints) ───────────────────────────────────
startCraftingEquipment: (blueprintId: string) => {
const state = get();
const recipe = CRAFTING_RECIPES[blueprintId];
if (!recipe) return false;
// Check if player has the blueprint
if (!state.lootInventory.blueprints.includes(blueprintId)) return false;
// Check materials
const { canCraft, missingMaterials, missingMana } = canCraftRecipe(
recipe,
state.lootInventory.materials,
state.rawMana
);
if (!canCraft) return false;
// Deduct materials
const newMaterials = { ...state.lootInventory.materials };
for (const [matId, amount] of Object.entries(recipe.materials)) {
newMaterials[matId] = (newMaterials[matId] || 0) - amount;
if (newMaterials[matId] <= 0) {
delete newMaterials[matId];
}
}
// Start crafting progress
set((state) => ({
lootInventory: {
...state.lootInventory,
materials: newMaterials,
},
rawMana: state.rawMana - recipe.manaCost,
currentAction: 'craft',
equipmentCraftingProgress: {
blueprintId,
equipmentTypeId: recipe.equipmentTypeId,
progress: 0,
required: recipe.craftTime,
manaSpent: recipe.manaCost,
},
}));
return true;
},
cancelEquipmentCrafting: () => {
set((state) => {
const progress = state.equipmentCraftingProgress;
if (!progress) return {};
const recipe = CRAFTING_RECIPES[progress.blueprintId];
if (!recipe) return { currentAction: 'meditate', equipmentCraftingProgress: null };
// Refund 50% of mana
const manaRefund = Math.floor(progress.manaSpent * 0.5);
return {
currentAction: 'meditate',
equipmentCraftingProgress: null,
rawMana: state.rawMana + manaRefund,
log: [`🚫 Equipment crafting cancelled. Refunded ${manaRefund} mana.`, ...state.log.slice(0, 49)],
};
});
},
deleteMaterial: (materialId: string, amount: number) => {
set((state) => {
const currentAmount = state.lootInventory.materials[materialId] || 0;
const newAmount = Math.max(0, currentAmount - amount);
const newMaterials = { ...state.lootInventory.materials };
if (newAmount <= 0) {
delete newMaterials[materialId];
} else {
newMaterials[materialId] = newAmount;
}
const dropName = materialId; // Could look up in LOOT_DROPS for proper name
return {
lootInventory: {
...state.lootInventory,
materials: newMaterials,
},
log: [`🗑️ Deleted ${amount}x ${dropName}.`, ...state.log.slice(0, 49)],
};
});
},
};
}
@@ -620,6 +717,59 @@ export function processCraftingTick(
}
}
// Process equipment crafting progress
if (state.currentAction === 'craft' && state.equipmentCraftingProgress) {
const craft = state.equipmentCraftingProgress;
const progress = craft.progress + 0.04; // HOURS_PER_TICK
if (progress >= craft.required) {
// Crafting complete - create the equipment!
const recipe = CRAFTING_RECIPES[craft.blueprintId];
const equipType = recipe ? EQUIPMENT_TYPES[recipe.equipmentTypeId] : null;
if (recipe && equipType) {
const instanceId = generateInstanceId();
const newInstance: EquipmentInstance = {
instanceId,
typeId: recipe.equipmentTypeId,
name: recipe.name,
enchantments: [],
usedCapacity: 0,
totalCapacity: equipType.baseCapacity,
rarity: recipe.rarity,
quality: 100,
};
updates = {
...updates,
equipmentCraftingProgress: null,
currentAction: 'meditate',
equipmentInstances: {
...state.equipmentInstances,
[instanceId]: newInstance,
},
totalCraftsCompleted: (state.totalCraftsCompleted || 0) + 1,
log: [`🔨 Crafted ${recipe.name}!`, ...log],
};
} else {
updates = {
...updates,
equipmentCraftingProgress: null,
currentAction: 'meditate',
log: ['⚠️ Crafting failed - invalid recipe!', ...log],
};
}
} else {
updates = {
...updates,
equipmentCraftingProgress: {
...craft,
progress,
},
};
}
}
return updates;
}

View File

@@ -0,0 +1,257 @@
// ─── Crafting Recipes ─────────────────────────────────────────────────────────
// Defines what materials are needed to craft equipment from blueprints
import type { EquipmentSlot } from '../types';
export interface CraftingRecipe {
id: string; // Blueprint ID (matches loot drop)
equipmentTypeId: string; // Resulting equipment type ID
name: string; // Display name
description: string;
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
materials: Record<string, number>; // materialId -> count required
manaCost: number; // Raw mana cost to craft
craftTime: number; // Hours to craft
minFloor: number; // Minimum floor where blueprint drops
unlocked: boolean; // Whether the player has discovered this
}
export const CRAFTING_RECIPES: Record<string, CraftingRecipe> = {
// ─── Staff Blueprints ───
staffBlueprint: {
id: 'staffBlueprint',
equipmentTypeId: 'oakStaff',
name: 'Oak Staff',
description: 'A sturdy oak staff with decent mana capacity.',
rarity: 'uncommon',
materials: {
manaCrystalDust: 5,
arcaneShard: 2,
},
manaCost: 200,
craftTime: 4,
minFloor: 10,
unlocked: false,
},
wandBlueprint: {
id: 'wandBlueprint',
equipmentTypeId: 'crystalWand',
name: 'Crystal Wand',
description: 'A wand tipped with a small crystal. Excellent for elemental enchantments.',
rarity: 'rare',
materials: {
manaCrystalDust: 8,
arcaneShard: 4,
elementalCore: 1,
},
manaCost: 500,
craftTime: 6,
minFloor: 20,
unlocked: false,
},
robeBlueprint: {
id: 'robeBlueprint',
equipmentTypeId: 'scholarRobe',
name: 'Scholar Robe',
description: 'A robe worn by scholars and researchers.',
rarity: 'rare',
materials: {
manaCrystalDust: 6,
arcaneShard: 3,
elementalCore: 1,
},
manaCost: 400,
craftTime: 5,
minFloor: 25,
unlocked: false,
},
artifactBlueprint: {
id: 'artifactBlueprint',
equipmentTypeId: 'arcanistStaff',
name: 'Arcanist Staff',
description: 'A staff designed for advanced spellcasters. High capacity for complex enchantments.',
rarity: 'legendary',
materials: {
manaCrystalDust: 20,
arcaneShard: 10,
elementalCore: 5,
voidEssence: 2,
celestialFragment: 1,
},
manaCost: 2000,
craftTime: 12,
minFloor: 60,
unlocked: false,
},
// ─── Additional Blueprints ───
battlestaffBlueprint: {
id: 'battlestaffBlueprint',
equipmentTypeId: 'battlestaff',
name: 'Battlestaff',
description: 'A reinforced staff suitable for both casting and combat.',
rarity: 'rare',
materials: {
manaCrystalDust: 10,
arcaneShard: 5,
elementalCore: 2,
},
manaCost: 600,
craftTime: 6,
minFloor: 30,
unlocked: false,
},
catalystBlueprint: {
id: 'catalystBlueprint',
equipmentTypeId: 'fireCatalyst',
name: 'Fire Catalyst',
description: 'A catalyst attuned to fire magic. Enhances fire enchantments.',
rarity: 'rare',
materials: {
manaCrystalDust: 8,
arcaneShard: 4,
elementalCore: 3,
},
manaCost: 500,
craftTime: 5,
minFloor: 25,
unlocked: false,
},
shieldBlueprint: {
id: 'shieldBlueprint',
equipmentTypeId: 'runicShield',
name: 'Runic Shield',
description: 'A shield engraved with protective runes.',
rarity: 'rare',
materials: {
manaCrystalDust: 10,
arcaneShard: 6,
elementalCore: 2,
},
manaCost: 450,
craftTime: 5,
minFloor: 28,
unlocked: false,
},
hatBlueprint: {
id: 'hatBlueprint',
equipmentTypeId: 'wizardHat',
name: 'Wizard Hat',
description: 'A classic pointed wizard hat. Decent capacity for headwear.',
rarity: 'uncommon',
materials: {
manaCrystalDust: 4,
arcaneShard: 2,
},
manaCost: 150,
craftTime: 3,
minFloor: 12,
unlocked: false,
},
glovesBlueprint: {
id: 'glovesBlueprint',
equipmentTypeId: 'spellweaveGloves',
name: 'Spellweave Gloves',
description: 'Gloves woven with mana-conductive threads.',
rarity: 'uncommon',
materials: {
manaCrystalDust: 3,
arcaneShard: 2,
},
manaCost: 120,
craftTime: 3,
minFloor: 15,
unlocked: false,
},
bootsBlueprint: {
id: 'bootsBlueprint',
equipmentTypeId: 'travelerBoots',
name: 'Traveler Boots',
description: 'Comfortable boots for long journeys.',
rarity: 'uncommon',
materials: {
manaCrystalDust: 3,
arcaneShard: 1,
},
manaCost: 100,
craftTime: 2,
minFloor: 8,
unlocked: false,
},
ringBlueprint: {
id: 'ringBlueprint',
equipmentTypeId: 'silverRing',
name: 'Silver Ring',
description: 'A silver ring with decent magical conductivity.',
rarity: 'uncommon',
materials: {
manaCrystalDust: 2,
arcaneShard: 1,
},
manaCost: 80,
craftTime: 2,
minFloor: 10,
unlocked: false,
},
amuletBlueprint: {
id: 'amuletBlueprint',
equipmentTypeId: 'silverAmulet',
name: 'Silver Amulet',
description: 'A silver amulet with a small gem.',
rarity: 'uncommon',
materials: {
manaCrystalDust: 3,
arcaneShard: 2,
},
manaCost: 100,
craftTime: 3,
minFloor: 12,
unlocked: false,
},
};
// Helper functions
export function getRecipeByBlueprint(blueprintId: string): CraftingRecipe | undefined {
return CRAFTING_RECIPES[blueprintId];
}
export function canCraftRecipe(
recipe: CraftingRecipe,
materials: Record<string, number>,
rawMana: number
): { canCraft: boolean; missingMaterials: Record<string, number>; missingMana: number } {
const missingMaterials: Record<string, number> = {};
let canCraft = true;
for (const [matId, required] of Object.entries(recipe.materials)) {
const available = materials[matId] || 0;
if (available < required) {
missingMaterials[matId] = required - available;
canCraft = false;
}
}
const missingMana = Math.max(0, recipe.manaCost - rawMana);
if (missingMana > 0) {
canCraft = false;
}
return { canCraft, missingMaterials, missingMana };
}
// Get all recipes available based on unlocked blueprints
export function getAvailableRecipes(unlockedBlueprints: string[]): CraftingRecipe[] {
return unlockedBlueprints
.map(bpId => CRAFTING_RECIPES[bpId])
.filter(Boolean);
}

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 } 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,227 +40,56 @@ import {
type FamiliarBonuses,
DEFAULT_FAMILIAR_BONUSES,
} from './familiar-slice';
import { rollLootDrops } from './data/loot-drops';
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)
@@ -287,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 {
@@ -490,6 +189,11 @@ function makeInitial(overrides: Partial<GameState> = {}): GameState {
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
// Floor Navigation
climbDirection: 'up',
clearedFloors: {},
lastClearedFloor: null,
spells: startSpells,
skills: overrides.skills || {},
@@ -555,6 +259,9 @@ function makeInitial(overrides: Partial<GameState> = {}): GameState {
blueprints: [],
},
lootDropsToday: 0,
// Equipment Crafting Progress
equipmentCraftingProgress: null,
// Achievements
achievements: {
@@ -578,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;
@@ -602,6 +304,9 @@ interface GameStore extends GameState, CraftingActions, FamiliarActions {
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
tierUpSkill: (skillId: string) => void;
// Inventory Management
updateLootInventory: (inventory: LootInventory) => void;
// Computed getters
getMaxMana: () => number;
getRegen: () => number;
@@ -617,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()),
@@ -967,6 +674,12 @@ export const useGameStore = create<GameStore>()(
if (floorHP <= 0) {
// Floor cleared
const wasGuardian = GUARDIANS[currentFloor];
const clearedFloors = state.clearedFloors;
const climbDirection = state.climbDirection;
// Mark this floor as cleared (needs respawn if we leave and return)
clearedFloors[currentFloor] = true;
const lastClearedFloor = currentFloor;
// ─── Loot Drop System ───
const lootDrops = rollLootDrops(currentFloor, !!wasGuardian, 0);
@@ -1003,11 +716,22 @@ export const useGameStore = create<GameStore>()(
}
}
currentFloor = currentFloor + 1;
if (currentFloor > 100) {
currentFloor = 100;
}
// Move to next floor based on direction
const nextFloor = climbDirection === 'up'
? Math.min(currentFloor + 1, 100)
: Math.max(currentFloor - 1, 1);
currentFloor = nextFloor;
floorMaxHP = getFloorMaxHP(currentFloor);
// Check if this floor was previously cleared (has enemies respawned?)
// Floors respawn when you leave them and come back
const floorWasCleared = clearedFloors[currentFloor];
if (floorWasCleared) {
// Floor has respawned - reset it but mark as uncleared
delete clearedFloors[currentFloor];
}
floorHP = floorMaxHP;
maxFloorReached = Math.max(maxFloorReached, currentFloor);
@@ -1019,6 +743,10 @@ export const useGameStore = create<GameStore>()(
// Reset ALL spell progress on floor change
equipmentSpellStates = equipmentSpellStates.map(s => ({ ...s, castProgress: 0 }));
spellState = { ...spellState, castProgress: 0 };
// Update clearedFloors in the state
set((s) => ({ ...s, clearedFloors, lastClearedFloor }));
break; // Exit the while loop - new floor
}
}
@@ -1184,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];
@@ -1490,42 +1116,7 @@ export const useGameStore = create<GameStore>()(
log: [`🌟 ${SKILLS_DEF[baseSkillId]?.name || baseSkillId} evolved to Tier ${nextTier}!`, ...state.log.slice(0, 49)],
});
},
startParallelStudySkill: (skillId: string) => {
const state = get();
if (state.parallelStudyTarget) return; // Already have parallel study
if (!state.currentStudyTarget) return; // Need primary study
const sk = SKILLS_DEF[skillId];
if (!sk) return;
const currentLevel = state.skills[skillId] || 0;
if (currentLevel >= sk.max) return;
// Can't study same thing in parallel
if (state.currentStudyTarget.id === skillId) return;
set({
parallelStudyTarget: {
type: 'skill',
id: skillId,
progress: state.skillProgress[skillId] || 0,
required: sk.studyTime,
},
log: [`📚 Started parallel study of ${sk.name}... (50% speed)`, ...state.log.slice(0, 49)],
});
},
cancelParallelStudy: () => {
set((state) => {
if (!state.parallelStudyTarget) return state;
return {
parallelStudyTarget: null,
log: ['📖 Parallel study cancelled.', ...state.log.slice(0, 49)],
};
});
},
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => {
const state = get();
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
@@ -1637,6 +1228,10 @@ export const useGameStore = create<GameStore>()(
});
},
updateLootInventory: (inventory: LootInventory) => {
set({ lootInventory: inventory });
},
startDesigningEnchantment: (name: string, equipmentTypeId: string, effects: DesignEffect[]) => {
const state = get();
@@ -1874,6 +1469,98 @@ export const useGameStore = create<GameStore>()(
if (!instance) return 0;
return instance.totalCapacity - instance.usedCapacity;
},
// ─── Equipment Crafting (from blueprints) ───────────────────────────────────
startCraftingEquipment: (blueprintId: string) => {
const state = get();
const recipe = CRAFTING_RECIPES[blueprintId];
if (!recipe) return false;
// Check if player has the blueprint
if (!state.lootInventory.blueprints.includes(blueprintId)) return false;
// Check materials and mana
const { canCraft } = canCraftRecipe(
recipe,
state.lootInventory.materials,
state.rawMana
);
if (!canCraft) return false;
// Deduct materials
const newMaterials = { ...state.lootInventory.materials };
for (const [matId, amount] of Object.entries(recipe.materials)) {
newMaterials[matId] = (newMaterials[matId] || 0) - amount;
if (newMaterials[matId] <= 0) {
delete newMaterials[matId];
}
}
// Start crafting progress
set((state) => ({
lootInventory: {
...state.lootInventory,
materials: newMaterials,
},
rawMana: state.rawMana - recipe.manaCost,
currentAction: 'craft',
equipmentCraftingProgress: {
blueprintId,
equipmentTypeId: recipe.equipmentTypeId,
progress: 0,
required: recipe.craftTime,
manaSpent: recipe.manaCost,
},
log: [`🔨 Started crafting ${recipe.name}...`, ...state.log.slice(0, 49)],
}));
return true;
},
cancelEquipmentCrafting: () => {
set((state) => {
const progress = state.equipmentCraftingProgress;
if (!progress) return {};
const recipe = CRAFTING_RECIPES[progress.blueprintId];
if (!recipe) return { currentAction: 'meditate', equipmentCraftingProgress: null };
// Refund 50% of mana
const manaRefund = Math.floor(progress.manaSpent * 0.5);
return {
currentAction: 'meditate',
equipmentCraftingProgress: null,
rawMana: state.rawMana + manaRefund,
log: [`🚫 Crafting cancelled. Refunded ${manaRefund} mana.`, ...state.log.slice(0, 49)],
};
});
},
deleteMaterial: (materialId: string, amount: number) => {
set((state) => {
const currentAmount = state.lootInventory.materials[materialId] || 0;
const newAmount = Math.max(0, currentAmount - amount);
const newMaterials = { ...state.lootInventory.materials };
if (newAmount <= 0) {
delete newMaterials[materialId];
} else {
newMaterials[materialId] = newAmount;
}
const dropName = LOOT_DROPS[materialId]?.name || materialId;
return {
lootInventory: {
...state.lootInventory,
materials: newMaterials,
},
log: [`🗑️ Deleted ${amount}x ${dropName}.`, ...state.log.slice(0, 49)],
};
});
},
}),
{
name: 'mana-loop-storage',
@@ -1908,6 +1595,9 @@ export const useGameStore = create<GameStore>()(
activeSpell: state.activeSpell,
currentAction: state.currentAction,
castProgress: state.castProgress,
climbDirection: state.climbDirection,
clearedFloors: state.clearedFloors,
lastClearedFloor: state.lastClearedFloor,
spells: state.spells,
skills: state.skills,
skillProgress: state.skillProgress,
@@ -1928,6 +1618,19 @@ export const useGameStore = create<GameStore>()(
designProgress: state.designProgress,
preparationProgress: state.preparationProgress,
applicationProgress: state.applicationProgress,
// Loot system
lootInventory: state.lootInventory,
lootDropsToday: state.lootDropsToday,
// Achievements
achievements: state.achievements,
totalDamageDealt: state.totalDamageDealt,
totalSpellsCast: state.totalSpellsCast,
totalCraftsCompleted: state.totalCraftsCompleted,
// Familiars
familiars: state.familiars,
activeFamiliarSlots: state.activeFamiliarSlots,
familiarSummonProgress: state.familiarSummonProgress,
totalFamiliarXpEarned: state.totalFamiliarXpEarned,
}),
}
)

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

@@ -206,6 +206,15 @@ export interface ApplicationProgress {
manaSpent: number; // Total mana spent so far
}
// Equipment Crafting Progress (crafting from blueprints)
export interface EquipmentCraftingProgress {
blueprintId: string; // Blueprint being crafted
equipmentTypeId: string; // Resulting equipment type
progress: number; // Hours spent crafting
required: number; // Total hours needed
manaSpent: number; // Mana spent so far
}
// Equipment spell state (for multi-spell casting)
export interface EquipmentSpellState {
spellId: string;
@@ -412,6 +421,11 @@ export interface GameState {
activeSpell: string;
currentAction: GameAction;
castProgress: number; // Progress towards next spell cast (0-1)
// Floor Navigation
climbDirection: 'up' | 'down'; // Direction of floor traversal
clearedFloors: Record<number, boolean>; // Floors that have been cleared (need respawn)
lastClearedFloor: number | null; // Last floor that was cleared (for respawn tracking)
// Spells
spells: Record<string, SpellState>;
@@ -431,6 +445,7 @@ export interface GameState {
designProgress: DesignProgress | null;
preparationProgress: PreparationProgress | null;
applicationProgress: ApplicationProgress | null;
equipmentCraftingProgress: EquipmentCraftingProgress | null;
// Unlocked enchantment effects for designing
unlockedEffects: string[]; // Effect IDs that have been researched

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