Compare commits

..

2 Commits

Author SHA1 Message Date
4748b81fe6 feat: implement attunement leveling and debug functions
All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 2m4s
- Add attunement XP system with level-scaled progression (100 * 3^(level-2) XP per level)
- Add addAttunementXP function with automatic level-up handling
- Add debug functions: debugUnlockAttunement, debugAddElementalMana, debugSetTime, debugAddAttunementXP, debugSetFloor
- Update attunement conversion to use level-scaled conversion rate
- Import getAttunementConversionRate, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL in store
2026-03-27 18:55:22 +00:00
a1b15cea74 feat: add attunement leveling system, debug tab, and UI improvements
- Add mana pools display to ManaDisplay component with collapsible elements
- Add Debug tab with reset game, mana debug, time control, attunement unlock, element unlock
- Remove ComboMeter from UI (header and SpireTab)
- Remove 'scrollCrafting' capability from Enchanter attunement
- Add attunement level scaling (exponential with level)
- Add XP progress bar and level display in AttunementsTab
- Add getAttunementConversionRate and getAttunementXPForLevel functions
- MAX_ATTUNEMENT_LEVEL = 10 with 3^level XP scaling
2026-03-27 18:51:13 +00:00
8 changed files with 680 additions and 31 deletions

View File

@@ -13,8 +13,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { RotateCcw } from 'lucide-react';
import { CraftingTab, SpireTab, SpellsTab, LabTab, SkillsTab, StatsTab, EquipmentTab, AttunementsTab } from '@/components/game/tabs';
import { ComboMeter, ActionButtons, CalendarDisplay, ManaDisplay, TimeDisplay } from '@/components/game';
import { CraftingTab, SpireTab, SpellsTab, LabTab, SkillsTab, StatsTab, EquipmentTab, AttunementsTab, DebugTab } from '@/components/game/tabs';
import { ActionButtons, CalendarDisplay, ManaDisplay, TimeDisplay } from '@/components/game';
import { LootInventoryDisplay } from '@/components/game/LootInventory';
import { AchievementsDisplay } from '@/components/game/AchievementsDisplay';
@@ -166,8 +166,6 @@ export default function ManaLoopGame() {
isPaused={store.isPaused}
togglePause={store.togglePause}
/>
<ComboMeter combo={store.combo} isClimbing={store.currentAction === 'climb'} />
</div>
</div>
</header>
@@ -181,9 +179,12 @@ export default function ManaLoopGame() {
rawMana={store.rawMana}
maxMana={maxMana}
effectiveRegen={effectiveRegen}
meditationMultiplier={meditationMultiplier}
clickMana={clickMana}
isGathering={isGathering}
onGatherStart={handleGatherStart}
onGatherEnd={handleGatherEnd}
elements={store.elements}
/>
{/* Action Buttons */}
@@ -238,6 +239,7 @@ export default function ManaLoopGame() {
<TabsTrigger value="crafting" className="text-xs px-2 py-1">🔧 Craft</TabsTrigger>
<TabsTrigger value="lab" className="text-xs px-2 py-1">🔬 Lab</TabsTrigger>
<TabsTrigger value="stats" className="text-xs px-2 py-1">📊 Stats</TabsTrigger>
<TabsTrigger value="debug" className="text-xs px-2 py-1">🔧 Debug</TabsTrigger>
<TabsTrigger value="grimoire" className="text-xs px-2 py-1">📖 Grimoire</TabsTrigger>
</TabsList>
@@ -288,6 +290,10 @@ export default function ManaLoopGame() {
<TabsContent value="grimoire">
{renderGrimoireTab()}
</TabsContent>
<TabsContent value="debug">
<DebugTab store={store} />
</TabsContent>
</Tabs>
</div>
</main>

View File

@@ -3,8 +3,10 @@
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 { Zap, ChevronDown, ChevronUp } from 'lucide-react';
import { fmt, fmtDec } from '@/lib/game/store';
import { ELEMENTS } from '@/lib/game/constants';
import { useState } from 'react';
interface ManaDisplayProps {
rawMana: number;
@@ -15,6 +17,7 @@ interface ManaDisplayProps {
isGathering: boolean;
onGatherStart: () => void;
onGatherEnd: () => void;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
}
export function ManaDisplay({
@@ -26,10 +29,19 @@ export function ManaDisplay({
isGathering,
onGatherStart,
onGatherEnd,
elements,
}: ManaDisplayProps) {
const [expanded, setExpanded] = useState(true);
// Get unlocked elements sorted by current amount
const unlockedElements = Object.entries(elements)
.filter(([, state]) => state.unlocked)
.sort((a, b) => b[1].current - a[1].current);
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardContent className="pt-4 space-y-3">
{/* Raw Mana - Main Display */}
<div>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold game-mono text-blue-400">{fmt(rawMana)}</span>
@@ -57,6 +69,54 @@ export function ManaDisplay({
Gather +{clickMana} Mana
{isGathering && <span className="ml-2 text-xs">(Holding...)</span>}
</Button>
{/* Elemental Mana Pools */}
{unlockedElements.length > 0 && (
<div className="border-t border-gray-700 pt-3 mt-3">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-between w-full text-xs text-gray-400 hover:text-gray-300 mb-2"
>
<span>Elemental Mana ({unlockedElements.length})</span>
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
{expanded && (
<div className="grid grid-cols-2 gap-2">
{unlockedElements.map(([id, state]) => {
const elem = ELEMENTS[id];
if (!elem) return null;
return (
<div
key={id}
className="p-2 rounded bg-gray-800/50 border border-gray-700"
>
<div className="flex items-center gap-1 mb-1">
<span style={{ color: elem.color }}>{elem.sym}</span>
<span className="text-xs font-medium" style={{ color: elem.color }}>
{elem.name}
</span>
</div>
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden mb-1">
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.min(100, (state.current / state.max) * 100)}%`,
backgroundColor: elem.color
}}
/>
</div>
<div className="text-xs text-gray-400 game-mono">
{fmt(state.current)}/{fmt(state.max)}
</div>
</div>
);
})}
</div>
)}
</div>
)}
</CardContent>
</Card>
);

View File

@@ -1,13 +1,13 @@
'use client';
import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getTotalAttunementRegen, getAvailableSkillCategories } from '@/lib/game/data/attunements';
import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getTotalAttunementRegen, getAvailableSkillCategories, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL, getAttunementConversionRate } from '@/lib/game/data/attunements';
import { ELEMENTS } from '@/lib/game/constants';
import type { GameStore, AttunementState } from '@/lib/game/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Lock, Sparkles } from 'lucide-react';
import { Lock, Sparkles, TrendingUp } from 'lucide-react';
export interface AttunementsTabProps {
store: GameStore;
@@ -38,7 +38,7 @@ export function AttunementsTab({ store }: AttunementsTabProps) {
<CardContent>
<p className="text-sm text-gray-400 mb-3">
Attunements are magical bonds tied to specific body locations. Each attunement grants unique capabilities,
mana regeneration, and access to specialized skills.
mana regeneration, and access to specialized skills. Level them up to increase their power.
</p>
<div className="flex flex-wrap gap-2">
<Badge className="bg-teal-900/50 text-teal-300">
@@ -57,6 +57,11 @@ export function AttunementsTab({ store }: AttunementsTabProps) {
const state = attunements[id];
const isActive = state?.active;
const isUnlocked = state?.active || def.unlocked;
const level = state?.level || 1;
const xp = state?.experience || 0;
const xpNeeded = getAttunementXPForLevel(level + 1);
const xpProgress = xpNeeded > 0 ? (xp / xpNeeded) * 100 : 100;
const isMaxLevel = level >= MAX_ATTUNEMENT_LEVEL;
// Get primary mana element info
const primaryElem = def.primaryManaType ? ELEMENTS[def.primaryManaType] : null;
@@ -65,6 +70,11 @@ export function AttunementsTab({ store }: AttunementsTabProps) {
const currentMana = def.primaryManaType ? store.elements[def.primaryManaType]?.current || 0 : 0;
const maxMana = def.primaryManaType ? store.elements[def.primaryManaType]?.max || 50 : 50;
// Calculate level-scaled stats
const levelMult = Math.pow(1.5, level - 1);
const scaledRegen = def.rawManaRegen * levelMult;
const scaledConversion = getAttunementConversionRate(id, level);
return (
<Card
key={id}
@@ -98,7 +108,7 @@ export function AttunementsTab({ store }: AttunementsTabProps) {
)}
{isActive && (
<Badge className="text-xs" style={{ backgroundColor: `${def.color}30`, color: def.color }}>
Active
Lv.{level}
</Badge>
)}
</div>
@@ -134,20 +144,51 @@ export function AttunementsTab({ store }: AttunementsTabProps) {
)}
</div>
{/* Stats */}
{/* Stats with level scaling */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="p-2 bg-gray-800/50 rounded">
<div className="text-gray-500">Raw Regen</div>
<div className="text-green-400 font-semibold">+{def.rawManaRegen}/hr</div>
<div className="text-green-400 font-semibold">
+{scaledRegen.toFixed(2)}/hr
{level > 1 && <span className="text-xs ml-1">({((levelMult - 1) * 100).toFixed(0)}% bonus)</span>}
</div>
</div>
<div className="p-2 bg-gray-800/50 rounded">
<div className="text-gray-500">Conversion</div>
<div className="text-cyan-400 font-semibold">
{def.conversionRate > 0 ? `${def.conversionRate}/hr` : '—'}
{scaledConversion > 0 ? `${scaledConversion.toFixed(2)}/hr` : '—'}
{level > 1 && scaledConversion > 0 && <span className="text-xs ml-1">({((levelMult - 1) * 100).toFixed(0)}% bonus)</span>}
</div>
</div>
</div>
{/* XP Progress Bar */}
{isUnlocked && state && !isMaxLevel && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500 flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
XP Progress
</span>
<span className="text-amber-400">{xp} / {xpNeeded}</span>
</div>
<Progress
value={xpProgress}
className="h-2 bg-gray-800"
/>
<div className="text-xs text-gray-500">
{isMaxLevel ? 'Max Level' : `${xpNeeded - xp} XP to Level ${level + 1}`}
</div>
</div>
)}
{/* Max Level Indicator */}
{isMaxLevel && (
<div className="text-xs text-amber-400 text-center font-semibold">
MAX LEVEL
</div>
)}
{/* Capabilities */}
<div className="space-y-1">
<div className="text-xs text-gray-500">Capabilities</div>
@@ -156,14 +197,13 @@ export function AttunementsTab({ store }: AttunementsTabProps) {
<Badge key={cap} variant="outline" className="text-xs">
{cap === 'enchanting' && '✨ Enchanting'}
{cap === 'disenchanting' && '🔄 Disenchant'}
{cap === 'scrollCrafting' && '📜 Scrolls'}
{cap === 'pacts' && '🤝 Pacts'}
{cap === 'guardianPowers' && '💜 Guardian Powers'}
{cap === 'elementalMastery' && '🌟 Elem. Mastery'}
{cap === 'golemCrafting' && '🗿 Golems'}
{cap === 'gearCrafting' && '⚒️ Gear'}
{cap === 'earthShaping' && '⛰️ Earth Shaping'}
{!['enchanting', 'disenchanting', 'scrollCrafting', 'pacts', 'guardianPowers',
{!['enchanting', 'disenchanting', 'pacts', 'guardianPowers',
'elementalMastery', 'golemCrafting', 'gearCrafting', 'earthShaping'].includes(cap) && cap}
</Badge>
))}
@@ -176,13 +216,6 @@ export function AttunementsTab({ store }: AttunementsTabProps) {
🔒 {def.unlockCondition}
</div>
)}
{/* Level for unlocked attunements */}
{isUnlocked && state && (
<div className="text-xs text-gray-400">
Level {state.level} {state.experience} XP
</div>
)}
</CardContent>
</Card>
);

View File

@@ -0,0 +1,380 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
RotateCcw, Bug, Plus, Minus, Lock, Unlock, Zap,
Clock, Star, AlertTriangle, Sparkles, Settings
} from 'lucide-react';
import type { GameStore } from '@/lib/game/types';
import { ATTUNEMENTS_DEF } from '@/lib/game/data/attunements';
import { ELEMENTS } from '@/lib/game/constants';
import { fmt } from '@/lib/game/store';
interface DebugTabProps {
store: GameStore;
}
export function DebugTab({ store }: DebugTabProps) {
const [confirmReset, setConfirmReset] = useState(false);
const handleReset = () => {
if (confirmReset) {
store.resetGame();
setConfirmReset(false);
} else {
setConfirmReset(true);
setTimeout(() => setConfirmReset(false), 3000);
}
};
const handleAddMana = (amount: number) => {
// Use gatherMana multiple times to add mana
for (let i = 0; i < amount; i++) {
store.gatherMana();
}
};
const handleUnlockAttunement = (id: string) => {
// Debug action to unlock attunements
if (store.debugUnlockAttunement) {
store.debugUnlockAttunement(id);
}
};
const handleUnlockElement = (element: string) => {
store.unlockElement(element);
};
const handleAddElementalMana = (element: string, amount: number) => {
const elem = store.elements[element];
if (elem?.unlocked) {
// Add directly to element pool - need to implement in store
if (store.debugAddElementalMana) {
store.debugAddElementalMana(element, amount);
}
}
};
const handleSetTime = (day: number, hour: number) => {
if (store.debugSetTime) {
store.debugSetTime(day, hour);
}
};
const handleAddAttunementXP = (id: string, amount: number) => {
if (store.debugAddAttunementXP) {
store.debugAddAttunementXP(id, amount);
}
};
return (
<div className="space-y-4">
{/* Warning Banner */}
<Card className="bg-amber-900/20 border-amber-600/50">
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-amber-400">
<AlertTriangle className="w-5 h-5" />
<span className="font-semibold">Debug Mode</span>
</div>
<p className="text-sm text-amber-300/70 mt-1">
These tools are for development and testing. Using them may break game balance or save data.
</p>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Game Reset */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-red-400 text-sm flex items-center gap-2">
<RotateCcw className="w-4 h-4" />
Game Reset
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-gray-400">
Reset all game progress and start fresh. This cannot be undone.
</p>
<Button
className={`w-full ${confirmReset ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-700 hover:bg-gray-600'}`}
onClick={handleReset}
>
{confirmReset ? (
<>
<AlertTriangle className="w-4 h-4 mr-2" />
Click Again to Confirm Reset
</>
) : (
<>
<RotateCcw className="w-4 h-4 mr-2" />
Reset Game
</>
)}
</Button>
</CardContent>
</Card>
{/* Mana Debug */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-blue-400 text-sm flex items-center gap-2">
<Zap className="w-4 h-4" />
Mana Debug
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-xs text-gray-400 mb-2">
Current: {fmt(store.rawMana)} / {fmt(store.getMaxMana())}
</div>
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={() => handleAddMana(10)}>
<Plus className="w-3 h-3 mr-1" /> +10
</Button>
<Button size="sm" variant="outline" onClick={() => handleAddMana(100)}>
<Plus className="w-3 h-3 mr-1" /> +100
</Button>
<Button size="sm" variant="outline" onClick={() => handleAddMana(1000)}>
<Plus className="w-3 h-3 mr-1" /> +1K
</Button>
<Button size="sm" variant="outline" onClick={() => handleAddMana(10000)}>
<Plus className="w-3 h-3 mr-1" /> +10K
</Button>
</div>
<Separator className="bg-gray-700" />
<div className="text-xs text-gray-400">Fill to max:</div>
<Button
size="sm"
className="w-full bg-blue-600 hover:bg-blue-700"
onClick={() => {
const max = store.getMaxMana();
const current = store.rawMana;
for (let i = 0; i < Math.floor(max - current); i++) {
store.gatherMana();
}
}}
>
Fill Mana
</Button>
</CardContent>
</Card>
{/* Time Control */}
<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">
<Clock className="w-4 h-4" />
Time Control
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-xs text-gray-400">
Current: Day {store.day}, Hour {store.hour}
</div>
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={() => handleSetTime(1, 0)}>
Day 1
</Button>
<Button size="sm" variant="outline" onClick={() => handleSetTime(10, 0)}>
Day 10
</Button>
<Button size="sm" variant="outline" onClick={() => handleSetTime(20, 0)}>
Day 20
</Button>
<Button size="sm" variant="outline" onClick={() => handleSetTime(30, 0)}>
Day 30
</Button>
</div>
<Separator className="bg-gray-700" />
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => store.togglePause()}
>
{store.paused ? '▶ Resume' : '⏸ Pause'}
</Button>
</div>
</CardContent>
</Card>
{/* Attunement Unlock */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-purple-400 text-sm flex items-center gap-2">
<Sparkles className="w-4 h-4" />
Attunements
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Object.entries(ATTUNEMENTS_DEF).map(([id, def]) => {
const isActive = store.attunements?.[id]?.active;
const level = store.attunements?.[id]?.level || 1;
const xp = store.attunements?.[id]?.experience || 0;
return (
<div key={id} className="flex items-center justify-between p-2 bg-gray-800/50 rounded">
<div className="flex items-center gap-2">
<span>{def.icon}</span>
<div>
<div className="text-sm font-medium">{def.name}</div>
{isActive && (
<div className="text-xs text-gray-400">Lv.{level} {xp} XP</div>
)}
</div>
</div>
<div className="flex gap-1">
{!isActive && (
<Button
size="sm"
variant="outline"
onClick={() => handleUnlockAttunement(id)}
>
<Unlock className="w-3 h-3" />
</Button>
)}
{isActive && (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleAddAttunementXP(id, 50)}
>
+50 XP
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleAddAttunementXP(id, 500)}
>
+500 XP
</Button>
</>
)}
</div>
</div>
);
})}
</CardContent>
</Card>
{/* Element Unlock */}
<Card className="bg-gray-900/80 border-gray-700 md:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-green-400 text-sm flex items-center gap-2">
<Star className="w-4 h-4" />
Elemental Mana
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{Object.entries(ELEMENTS).map(([id, def]) => {
const elem = store.elements[id];
const isUnlocked = elem?.unlocked;
return (
<div
key={id}
className={`p-2 rounded border ${
isUnlocked ? 'border-gray-600' : 'border-gray-800 opacity-60'
}`}
style={{
borderColor: isUnlocked ? def.color : undefined
}}
>
<div className="flex items-center justify-between mb-1">
<span style={{ color: def.color }}>{def.sym}</span>
{!isUnlocked && (
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0"
onClick={() => handleUnlockElement(id)}
>
<Lock className="w-3 h-3" />
</Button>
)}
</div>
<div className="text-xs" style={{ color: def.color }}>{def.name}</div>
{isUnlocked && (
<div className="text-xs text-gray-400 mt-1">
{elem.current.toFixed(0)}/{elem.max}
</div>
)}
{isUnlocked && (
<Button
size="sm"
variant="ghost"
className="h-5 w-full mt-1 text-xs"
onClick={() => handleAddElementalMana(id, 100)}
>
+100
</Button>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Skills Debug */}
<Card className="bg-gray-900/80 border-gray-700 md:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
<Settings className="w-4 h-4" />
Quick Actions
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onClick={() => {
// Unlock all base elements
['fire', 'water', 'air', 'earth', 'light', 'dark', 'life', 'death'].forEach(e => {
if (!store.elements[e]?.unlocked) {
store.unlockElement(e);
}
});
}}
>
Unlock All Base Elements
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
// Unlock utility elements
['mental', 'transference', 'force'].forEach(e => {
if (!store.elements[e]?.unlocked) {
store.unlockElement(e);
}
});
}}
>
Unlock Utility Elements
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
// Max floor
if (store.debugSetFloor) {
store.debugSetFloor(100);
}
}}
>
Skip to Floor 100
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -13,7 +13,7 @@ import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF } from '@/lib/game/constant
import { fmt, fmtDec, getFloorElement, canAffordSpellCost } from '@/lib/game/store';
import { getActiveEquipmentSpells, getTotalDPS } from '@/lib/game/computed-stats';
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
import { ComboMeter, CraftingProgress, StudyProgress } from '@/components/game';
import { CraftingProgress, StudyProgress } from '@/components/game';
import { getUnifiedEffects } from '@/lib/game/effects';
interface SpireTabProps {
@@ -200,9 +200,6 @@ export function SpireTab({ store }: SpireTabProps) {
</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">

View File

@@ -9,3 +9,4 @@ export { SkillsTab } from './SkillsTab';
export { StatsTab } from './StatsTab';
export { EquipmentTab } from './EquipmentTab';
export { AttunementsTab } from './AttunementsTab';
export { DebugTab } from './DebugTab';

View File

@@ -31,7 +31,7 @@ export const ATTUNEMENTS_DEF: Record<string, AttunementDef> = {
rawManaRegen: 0.5,
conversionRate: 0.2, // Converts 0.2 raw mana to transference per hour
unlocked: true, // Starting attunement
capabilities: ['enchanting', 'disenchanting', 'scrollCrafting'],
capabilities: ['enchanting', 'disenchanting'],
skillCategories: ['enchant', 'effectResearch'],
},
@@ -88,13 +88,38 @@ export function getUnlockedAttunements(attunements: Record<string, { active: boo
.filter(Boolean);
}
// Helper function to calculate total raw mana regen from attunements
// Helper function to calculate total raw mana regen from attunements (with level scaling)
export function getTotalAttunementRegen(attunements: Record<string, { active: boolean; level: number; experience: number }>): number {
return Object.entries(attunements)
.filter(([id, state]) => state.active)
.reduce((total, [id]) => total + (ATTUNEMENTS_DEF[id]?.rawManaRegen || 0), 0);
.filter(([, state]) => state.active)
.reduce((total, [id, state]) => {
const def = ATTUNEMENTS_DEF[id];
if (!def) return total;
// Exponential scaling: base * (1.5 ^ (level - 1))
const levelMult = Math.pow(1.5, (state.level || 1) - 1);
return total + def.rawManaRegen * levelMult;
}, 0);
}
// Get conversion rate with level scaling
export function getAttunementConversionRate(attunementId: string, level: number): number {
const def = ATTUNEMENTS_DEF[attunementId];
if (!def || def.conversionRate <= 0) return 0;
// Exponential scaling: base * (1.5 ^ (level - 1))
return def.conversionRate * Math.pow(1.5, (level || 1) - 1);
}
// XP required for attunement level
export function getAttunementXPForLevel(level: number): number {
// Level 2: 100 XP, Level 3: 300 XP, Level 4: 900 XP, etc.
// Exponential: 100 * (3 ^ (level - 2))
if (level <= 1) return 0;
return Math.floor(100 * Math.pow(3, level - 2));
}
// Max attunement level
export const MAX_ATTUNEMENT_LEVEL = 10;
// Helper function to get mana types from active attunements and pacts
export function getAttunementManaTypes(
attunements: Record<string, { active: boolean; level: number; experience: number }>,

View File

@@ -39,7 +39,7 @@ import {
} from './crafting-slice';
import { EQUIPMENT_TYPES } from './data/equipment';
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
import { ATTUNEMENTS_DEF, getTotalAttunementRegen } from './data/attunements';
import { ATTUNEMENTS_DEF, getTotalAttunementRegen, getAttunementConversionRate, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from './data/attunements';
// Default empty effects for when effects aren't provided
const DEFAULT_EFFECTS: ComputedEffects = {
@@ -568,6 +568,16 @@ interface GameStore extends GameState, CraftingActions {
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
tierUpSkill: (skillId: string) => void;
// Attunement XP and leveling
addAttunementXP: (attunementId: string, amount: number) => void;
// Debug functions
debugUnlockAttunement: (attunementId: string) => void;
debugAddElementalMana: (element: string, amount: number) => void;
debugSetTime: (day: number, hour: number) => void;
debugAddAttunementXP: (attunementId: string, amount: number) => void;
debugSetFloor: (floor: number) => void;
// Computed getters
getMaxMana: () => number;
getRegen: () => number;
@@ -679,8 +689,11 @@ export const useGameStore = create<GameStore>()(
const elem = elements[attDef.primaryManaType];
if (!elem || !elem.unlocked) return;
// Get level-scaled conversion rate
const scaledConversionRate = getAttunementConversionRate(attId, attState.level || 1);
// Convert raw mana to primary type
const conversionAmount = attDef.conversionRate * HOURS_PER_TICK;
const conversionAmount = scaledConversionRate * HOURS_PER_TICK;
const actualConversion = Math.min(conversionAmount, rawMana, elem.max - elem.current);
if (actualConversion > 0) {
@@ -1668,6 +1681,140 @@ export const useGameStore = create<GameStore>()(
if (!instance) return 0;
return instance.totalCapacity - instance.usedCapacity;
},
// Attunement XP and leveling
addAttunementXP: (attunementId: string, amount: number) => {
const state = get();
const attState = state.attunements[attunementId];
if (!attState?.active) return;
let newXP = attState.experience + amount;
let newLevel = attState.level;
// Check for level ups
while (newLevel < MAX_ATTUNEMENT_LEVEL) {
const xpNeeded = getAttunementXPForLevel(newLevel + 1);
if (newXP >= xpNeeded) {
newXP -= xpNeeded;
newLevel++;
} else {
break;
}
}
// Cap XP at max level
if (newLevel >= MAX_ATTUNEMENT_LEVEL) {
newXP = 0;
}
set({
attunements: {
...state.attunements,
[attunementId]: {
...attState,
level: newLevel,
experience: newXP,
},
},
log: newLevel > attState.level
? [`🌟 ${attunementId} attunement reached Level ${newLevel}!`, ...state.log.slice(0, 49)]
: state.log,
});
},
// Debug functions
debugUnlockAttunement: (attunementId: string) => {
const state = get();
const def = ATTUNEMENTS_DEF[attunementId];
if (!def) return;
set({
attunements: {
...state.attunements,
[attunementId]: {
id: attunementId,
active: true,
level: 1,
experience: 0,
},
},
// Unlock the primary mana type if applicable
elements: def.primaryManaType && state.elements[def.primaryManaType]
? {
...state.elements,
[def.primaryManaType]: {
...state.elements[def.primaryManaType],
unlocked: true,
},
}
: state.elements,
log: [`🔓 Debug: Unlocked ${def.name} attunement!`, ...state.log.slice(0, 49)],
});
},
debugAddElementalMana: (element: string, amount: number) => {
const state = get();
const elem = state.elements[element];
if (!elem?.unlocked) return;
set({
elements: {
...state.elements,
[element]: {
...elem,
current: Math.min(elem.current + amount, elem.max * 10), // Allow overflow
},
},
});
},
debugSetTime: (day: number, hour: number) => {
set({
day,
hour,
incursionStrength: getIncursionStrength(day, hour),
});
},
debugAddAttunementXP: (attunementId: string, amount: number) => {
const state = get();
const attState = state.attunements[attunementId];
if (!attState) return;
let newXP = attState.experience + amount;
let newLevel = attState.level;
while (newLevel < MAX_ATTUNEMENT_LEVEL) {
const xpNeeded = getAttunementXPForLevel(newLevel + 1);
if (newXP >= xpNeeded) {
newXP -= xpNeeded;
newLevel++;
} else {
break;
}
}
set({
attunements: {
...state.attunements,
[attunementId]: {
...attState,
level: newLevel,
experience: newXP,
},
},
});
},
debugSetFloor: (floor: number) => {
const state = get();
set({
currentFloor: floor,
floorHP: getFloorMaxHP(floor),
floorMaxHP: getFloorMaxHP(floor),
maxFloorReached: Math.max(state.maxFloorReached, floor),
});
},
}),
{
name: 'mana-loop-storage',