feat: recreate Achievements tab with category sections, progress tracking, and hidden achievement logic
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s

This commit is contained in:
2026-05-19 14:44:27 +02:00
parent 50a9a62060
commit 639d396f80
15 changed files with 1396 additions and 4 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
# Circular Dependencies
Generated: 2026-05-19T10:51:45.659Z
Generated: 2026-05-19T11:53:37.574Z
Found: 3 circular chain(s) — these MUST be fixed before modifying involved files.
1. Processed 121 files (1.3s) (4 warnings)
1. Processed 121 files (1.2s) (4 warnings)
2. 1) data/equipment/index.ts > data/equipment/utils.ts
3. 2) data/golems/index.ts > data/golems/utils.ts
+12 -2
View File
@@ -1,6 +1,6 @@
{
"_meta": {
"generated": "2026-05-19T10:51:44.106Z",
"generated": "2026-05-19T11:53:36.198Z",
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
},
@@ -409,7 +409,9 @@
],
"stores/combat-actions.ts": [
"constants.ts",
"effects/discipline-effects.ts",
"stores/combat-state.types.ts",
"stores/discipline-slice.ts",
"stores/prestigeStore.ts",
"types.ts",
"utils/index.ts"
@@ -451,7 +453,9 @@
"utils/discipline-math.ts"
],
"stores/gameActions.ts": [
"effects/discipline-effects.ts",
"stores/combatStore.ts",
"stores/discipline-slice.ts",
"stores/manaStore.ts",
"stores/prestigeStore.ts",
"stores/uiStore.ts",
@@ -460,8 +464,10 @@
"stores/gameHooks.ts": [
"constants.ts",
"effects.ts",
"effects/discipline-effects.ts",
"stores/combatStore.ts",
"stores/craftingStore.ts",
"stores/discipline-slice.ts",
"stores/gameStore.ts",
"stores/manaStore.ts",
"stores/prestigeStore.ts",
@@ -470,7 +476,9 @@
],
"stores/gameLoopActions.ts": [
"constants.ts",
"effects/discipline-effects.ts",
"stores/combatStore.ts",
"stores/discipline-slice.ts",
"stores/manaStore.ts",
"stores/prestigeStore.ts",
"stores/uiStore.ts",
@@ -500,6 +508,7 @@
"stores/combat-state.types.ts",
"stores/combatStore.ts",
"stores/craftingStore.ts",
"stores/discipline-slice.ts",
"stores/gameHooks.ts",
"stores/gameStore.ts",
"stores/manaStore.ts",
@@ -554,7 +563,8 @@
"utils/combat-utils.ts": [
"constants.ts",
"data/enchantment-effects.ts",
"types.ts"
"types.ts",
"utils/mana-utils.ts"
],
"utils/discipline-math.ts": [
"types/disciplines.ts"
+11
View File
@@ -88,6 +88,15 @@ Mana-Loop/
│ │ │ │ └── index.tsx
│ │ │ ├── shared/
│ │ │ ├── tabs/
│ │ │ │ ├── DebugTab/
│ │ │ │ │ ├── AchievementDebugSection.tsx
│ │ │ │ │ ├── AttunementDebugSection.tsx
│ │ │ │ │ ├── DisciplineDebugSection.tsx
│ │ │ │ │ ├── ElementDebugSection.tsx
│ │ │ │ │ ├── GameStateDebugSection.tsx
│ │ │ │ │ ├── GolemDebugSection.tsx
│ │ │ │ │ ├── PactDebugSection.tsx
│ │ │ │ │ └── SpireDebugSection.tsx
│ │ │ │ ├── StatsTab/
│ │ │ │ │ ├── CombatStatsSection.tsx
│ │ │ │ │ ├── ElementStatsSection.tsx
@@ -95,6 +104,7 @@ Mana-Loop/
│ │ │ │ │ ├── ManaStatsSection.tsx
│ │ │ │ │ ├── PactStatusSection.tsx
│ │ │ │ │ └── StudyStatsSection.tsx
│ │ │ │ ├── AchievementsTab.tsx
│ │ │ │ ├── ActivityLog.tsx
│ │ │ │ ├── DisciplinesTab.tsx
│ │ │ │ ├── SpellsTab.tsx
@@ -147,6 +157,7 @@ Mana-Loop/
│ ├── game/
│ │ ├── __tests__/
│ │ │ ├── store-method-tests/
│ │ │ ├── achievements.test.ts
│ │ │ ├── bug-fixes.test.ts
│ │ │ ├── computed-stats.test.ts
│ │ │ └── regression-fixes.test.ts
+10
View File
@@ -44,6 +44,7 @@ import { LeftPanel } from './components/LeftPanel';
const DisciplinesTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.DisciplinesTab })));
const SpellsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.SpellsTab })));
const StatsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.StatsTab })));
const AchievementsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.AchievementsTab })));
const TabLoadingFallback = () => <div className="p-4 text-center text-gray-400">Loading...</div>;
@@ -233,6 +234,7 @@ export default function ManaLoopGame() {
<TabsTrigger value="stats" className="text-xs px-2 py-1">📊 Stats</TabsTrigger>
<TabsTrigger value="disciplines" className="text-xs px-2 py-1">📚 Disciplines</TabsTrigger>
<TabsTrigger value="grimoire" className="text-xs px-2 py-1">📖 Grimoire</TabsTrigger>
<TabsTrigger value="achievements" className="text-xs px-2 py-1">🏆 Achievements</TabsTrigger>
</TabsList>
<TabsContent value="spells">
@@ -262,6 +264,14 @@ export default function ManaLoopGame() {
<TabsContent value="grimoire">
<GrimoireTab />
</TabsContent>
<TabsContent value="achievements">
<ErrorBoundary fallback={<div className="p-4 text-red-400">achievements tab failed to load.</div>}>
<Suspense fallback={<TabLoadingFallback />}>
<AchievementsTab />
</Suspense>
</ErrorBoundary>
</TabsContent>
</Tabs>
</div>
</main>
@@ -0,0 +1,250 @@
'use client';
import { useState, useMemo, useEffect } from 'react';
import { useCombatStore } from '@/lib/game/stores';
import {
ACHIEVEMENTS,
ACHIEVEMENT_CATEGORY_COLORS,
getAchievementsByCategory,
isAchievementRevealed,
} from '@/lib/game/data/achievements';
import type { AchievementDef } from '@/lib/game/types';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { ScrollArea } from '@/components/ui/scroll-area';
import { SectionHeader } from '@/components/ui/section-header';
import { DebugName } from '@/components/game/debug/debug-context';
import { fmt } from '@/lib/game/stores';
// ─── Helpers ─────────────────────────────────────────────────────────────────
const CATEGORY_LABELS: Record<string, string> = {
combat: '⚔️ Combat',
progression: '📈 Progression',
crafting: '🔨 Crafting',
magic: '✨ Magic',
special: '🌟 Special',
};
function getProgressForAchievement(
achievement: AchievementDef,
progress: Record<string, number>,
): number {
return progress[achievement.id] ?? 0;
}
function getProgressPercent(achievement: AchievementDef, current: number): number {
if (achievement.requirement.value <= 0) return 100;
return Math.min(100, Math.round((current / achievement.requirement.value) * 100));
}
function formatReward(reward: AchievementDef['reward']): string {
const parts: string[] = [];
if (reward.insight) parts.push(`${reward.insight} insight`);
if (reward.manaBonus) parts.push(`+${reward.manaBonus} mana`);
if (reward.damageBonus) parts.push(`+${(reward.damageBonus * 100).toFixed(0)}% dmg`);
if (reward.regenBonus) parts.push(`+${reward.regenBonus} regen`);
if (reward.title) parts.push(`title: "${reward.title}"`);
if (reward.unlockEffect) parts.push(`unlock: ${reward.unlockEffect}`);
return parts.join(', ');
}
// ─── Category Section ────────────────────────────────────────────────────────
interface CategorySectionProps {
category: string;
achievements: AchievementDef[];
unlocked: string[];
progress: Record<string, number>;
collapsed: boolean;
onToggleCollapse: () => void;
}
function CategorySection({
category,
achievements,
unlocked,
progress,
collapsed,
onToggleCollapse,
}: CategorySectionProps) {
const color = ACHIEVEMENT_CATEGORY_COLORS[category] ?? '#9CA3AF';
const label = CATEGORY_LABELS[category] ?? category;
const unlockedCount = achievements.filter((a) => unlocked.includes(a.id)).length;
return (
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader
title={`${label} (${unlockedCount}/${achievements.length})`}
action={
<button
onClick={onToggleCollapse}
className="text-xs text-gray-400 hover:text-gray-200 transition-colors"
>
{collapsed ? 'Expand ▼' : 'Collapse ▲'}
</button>
}
/>
{!collapsed && (
<CardContent className="space-y-3">
{achievements.map((achievement) => {
const isUnlocked = unlocked.includes(achievement.id);
const currentProgress = getProgressForAchievement(achievement, progress);
const percent = getProgressPercent(achievement, currentProgress);
const revealed = isAchievementRevealed(achievement, currentProgress);
if (!revealed) {
return (
<div
key={achievement.id}
className="p-3 rounded border border-gray-700/50 bg-gray-800/30"
>
<div className="flex items-center gap-2">
<span className="text-gray-500">???</span>
<span className="text-xs text-gray-600 italic">
Hidden achievement keep progressing to reveal
</span>
</div>
<div className="mt-2">
<Progress value={percent} className="h-1.5" />
</div>
</div>
);
}
return (
<div
key={achievement.id}
className={`p-3 rounded border ${
isUnlocked
? 'border-green-700/50 bg-green-900/20'
: 'border-gray-700/50 bg-gray-800/30'
}`}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm" style={{ color }}>
{achievement.name}
</span>
{isUnlocked && (
<Badge className="bg-green-900/50 text-green-300 text-xs">
Unlocked
</Badge>
)}
</div>
<span className="text-xs text-gray-500">
{fmt(currentProgress)} / {fmt(achievement.requirement.value)}
</span>
</div>
<p className="text-xs text-gray-400 mb-2">{achievement.desc}</p>
<div className="flex items-center gap-2 mb-2">
<Progress value={percent} className="h-1.5 flex-1" />
<span className="text-xs text-gray-500 w-10 text-right">
{percent}%
</span>
</div>
<div className="text-xs text-gray-500">
<span className="text-gray-400">Reward:</span>{' '}
{formatReward(achievement.reward)}
</div>
</div>
);
})}
</CardContent>
)}
</Card>
);
}
// ─── Main Component ──────────────────────────────────────────────────────────
export function AchievementsTab() {
const achievements = useCombatStore((s) => s.achievements);
const [mounted, setMounted] = useState(false);
const [collapsedCategories, setCollapsedCategories] = useState<Record<string, boolean>>({});
useEffect(() => {
setMounted(true);
}, []);
const byCategory = useMemo(() => getAchievementsByCategory(), []);
const categories = useMemo(
() => Object.keys(byCategory).sort(),
[byCategory],
);
const totalAchievements = Object.keys(ACHIEVEMENTS).length;
const unlockedCount = achievements.unlocked.length;
const toggleCollapse = (category: string) => {
setCollapsedCategories((prev) => ({
...prev,
[category]: !prev[category],
}));
};
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading achievements
</div>
);
}
return (
<DebugName name="AchievementsTab">
<div className="space-y-4">
{/* Summary header */}
<Card className="bg-gray-900/60 border-gray-700">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-100">
Achievements
</h2>
<p className="text-sm text-gray-400">
Track your progress and unlock rewards
</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-amber-400">
{unlockedCount}
<span className="text-sm text-gray-500 font-normal">
/{totalAchievements}
</span>
</div>
<Progress
value={Math.round((unlockedCount / totalAchievements) * 100)}
className="h-2 w-32 mt-1"
/>
</div>
</div>
</CardContent>
</Card>
{/* Category sections */}
<ScrollArea className="h-[600px] pr-2">
<div className="space-y-4">
{categories.map((category) => (
<CategorySection
key={category}
category={category}
achievements={byCategory[category]}
unlocked={achievements.unlocked}
progress={achievements.progress}
collapsed={collapsedCategories[category] ?? false}
onToggleCollapse={() => toggleCollapse(category)}
/>
))}
</div>
</ScrollArea>
</div>
</DebugName>
);
}
AchievementsTab.displayName = 'AchievementsTab';
@@ -0,0 +1,83 @@
'use client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Trophy, CheckCircle, RotateCcw } from 'lucide-react';
import { useCombatStore } from '@/lib/game/stores';
import { ACHIEVEMENTS } from '@/lib/game/data/achievements';
export function AchievementDebugSection() {
const achievements = useCombatStore((s) => s.achievements);
const unlockedCount = achievements?.unlocked?.length || 0;
const totalCount = Object.keys(ACHIEVEMENTS).length;
const handleUnlockAll = () => {
useCombatStore.setState({
achievements: {
unlocked: Object.keys(ACHIEVEMENTS),
progress: Object.fromEntries(
Object.keys(ACHIEVEMENTS).map((id) => [id, ACHIEVEMENTS[id].requirement.value])
),
},
});
};
const handleResetAll = () => {
useCombatStore.setState({
achievements: {
unlocked: [],
progress: {},
},
});
};
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-yellow-400 text-sm flex items-center gap-2">
<Trophy className="w-4 h-4" />
Achievement Debug
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-xs text-gray-400">
Unlocked: {unlockedCount} / {totalCount}
</div>
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={handleUnlockAll}>
<CheckCircle className="w-3 h-3 mr-1" /> Unlock All
</Button>
<Button size="sm" variant="destructive" onClick={handleResetAll}>
<RotateCcw className="w-3 h-3 mr-1" /> Reset All
</Button>
</div>
<div className="space-y-1 max-h-48 overflow-y-auto">
{Object.entries(ACHIEVEMENTS).map(([id, def]) => {
const isUnlocked = achievements?.unlocked?.includes(id);
return (
<div
key={id}
className={`flex items-center justify-between p-2 rounded text-xs ${
isUnlocked ? 'bg-green-900/20 border border-green-600/50' : 'bg-gray-800/50'
}`}
>
<div>
<span className="font-medium">{def.name}</span>
<span className="text-gray-500 ml-2">({def.category})</span>
</div>
{isUnlocked && (
<CheckCircle className="w-3 h-3 text-green-400" />
)}
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
AchievementDebugSection.displayName = "AchievementDebugSection";
@@ -0,0 +1,88 @@
'use client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Sparkles, Unlock } from 'lucide-react';
import { ATTUNEMENTS_DEF } from '@/lib/game/data/attunements';
import { useAttunementStore } from '@/lib/game/stores';
import { useManaStore } from '@/lib/game/stores';
export function AttunementDebugSection() {
const attunements = useAttunementStore((s) => s.attunements);
const debugUnlockAttunement = useAttunementStore((s) => s.debugUnlockAttunement);
const addAttunementXP = useAttunementStore((s) => s.addAttunementXP);
const handleUnlockAttunement = (id: string) => {
if (debugUnlockAttunement) {
debugUnlockAttunement(id);
if (id === 'enchanter') {
useManaStore.getState().unlockElement('transference', 0);
}
}
};
const handleAddAttunementXP = (id: string, amount: number) => {
if (addAttunementXP) {
addAttunementXP(id, amount);
}
};
const handleUnlockAll = () => {
Object.keys(ATTUNEMENTS_DEF).forEach((id) => {
handleUnlockAttunement(id);
});
};
return (
<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">
<Button size="sm" variant="outline" onClick={handleUnlockAll}>
<Unlock className="w-3 h-3 mr-1" /> Unlock All
</Button>
{Object.entries(ATTUNEMENTS_DEF).map(([id, def]) => {
const isActive = attunements?.[id]?.active;
const level = attunements?.[id]?.level || 1;
const xp = 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-2">
<Button
size="sm"
variant="outline"
onClick={() => handleUnlockAttunement(id)}
>
<Unlock className="w-3 h-3 mr-1" /> Unlock
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleAddAttunementXP(id, 100)}
>
+100 XP
</Button>
</div>
</div>
);
})}
</CardContent>
</Card>
);
}
AttunementDebugSection.displayName = "AttunementDebugSection";
@@ -0,0 +1,135 @@
'use client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { BookOpen, Plus, Pause, Play } from 'lucide-react';
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
import { ALL_DISCIPLINES } from '@/lib/game/data/disciplines';
export function DisciplineDebugSection() {
const disciplines = useDisciplineStore((s) => s.disciplines);
const activeIds = useDisciplineStore((s) => s.activeIds);
const concurrentLimit = useDisciplineStore((s) => s.concurrentLimit);
const activate = useDisciplineStore((s) => s.activate);
const deactivate = useDisciplineStore((s) => s.deactivate);
const handleTogglePause = (id: string) => {
const disc = disciplines[id];
if (!disc) return;
if (disc.paused) {
activate(id);
} else {
deactivate(id);
// Re-activate with paused false — just activate again
activate(id);
}
};
const handleAddXP = (id: string, amount: number) => {
useDisciplineStore.setState((s) => {
const disc = s.disciplines[id];
if (!disc) return s;
return {
disciplines: {
...s.disciplines,
[id]: { ...disc, xp: disc.xp + amount },
},
};
});
};
const handleActivateAll = () => {
ALL_DISCIPLINES.forEach((d) => {
if (!activeIds.includes(d.id)) {
activate(d.id);
}
});
};
const handleDeactivateAll = () => {
activeIds.forEach((id) => {
deactivate(id);
});
};
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-indigo-400 text-sm flex items-center gap-2">
<BookOpen className="w-4 h-4" />
Disciplines
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2 flex-wrap mb-2">
<Button size="sm" variant="outline" onClick={handleActivateAll}>
<Play className="w-3 h-3 mr-1" /> Activate All
</Button>
<Button size="sm" variant="outline" onClick={handleDeactivateAll}>
<Pause className="w-3 h-3 mr-1" /> Deactivate All
</Button>
</div>
<div className="text-xs text-gray-400">
Active: {activeIds.length} / {concurrentLimit}
</div>
<div className="space-y-2 max-h-64 overflow-y-auto">
{ALL_DISCIPLINES.map((def) => {
const disc = disciplines[def.id];
const isActive = activeIds.includes(def.id);
const xp = disc?.xp || 0;
const isPaused = disc?.paused ?? true;
return (
<div
key={def.id}
className="flex items-center justify-between p-2 bg-gray-800/50 rounded"
>
<div>
<div className="text-sm font-medium">{def.name}</div>
<div className="text-xs text-gray-400">
{isActive ? `XP: ${xp}` : 'Inactive'}
</div>
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
onClick={() => handleAddXP(def.id, 100)}
>
<Plus className="w-3 h-3 mr-1" /> +100
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleAddXP(def.id, 1000)}
>
+1K
</Button>
<Button
size="sm"
variant={isActive ? 'default' : 'outline'}
onClick={() => {
if (isActive) {
deactivate(def.id);
} else {
activate(def.id);
}
}}
>
{isActive ? (
<Pause className="w-3 h-3" />
) : (
<Play className="w-3 h-3" />
)}
</Button>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
DisciplineDebugSection.displayName = "DisciplineDebugSection";
@@ -0,0 +1,92 @@
'use client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Star, Lock } from 'lucide-react';
import { useManaStore } from '@/lib/game/stores';
import { ELEMENTS } from '@/lib/game/constants';
export function ElementDebugSection() {
const elements = useManaStore((s) => s.elements);
const handleUnlockElement = (element: string) => {
useManaStore.getState().unlockElement(element, 0);
};
const handleAddElementalMana = (element: string, amount: number) => {
const elem = elements?.[element];
if (elem?.unlocked) {
useManaStore.getState().addElementMana(element, amount, elem.max);
}
};
const handleUnlockAll = () => {
Object.keys(elements || {}).forEach((id) => {
if (!elements[id]?.unlocked) {
handleUnlockElement(id);
}
});
};
return (
<Card className="bg-gray-900/80 border-gray-700">
<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="mb-3">
<Button size="sm" variant="outline" onClick={handleUnlockAll}>
<Lock className="w-3 h-3 mr-1" /> Unlock All Elements
</Button>
</div>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{Object.entries(elements || {}).map(([id, elem]) => {
const def = ELEMENTS[id];
return (
<div
key={id}
className={`p-2 rounded border text-center ${
elem.unlocked ? 'border-gray-600 bg-gray-800/50' : 'border-gray-800 opacity-60'
}`}
style={{
borderColor: elem.unlocked ? def?.color : undefined
}}
>
<div className="text-lg">{def?.sym}</div>
<div className="text-xs text-gray-400">{def?.name}</div>
<div className="text-xs text-gray-300 mt-1">
{elem.current}/{elem.max}
</div>
{!elem.unlocked && (
<Button
size="sm"
variant="outline"
className="mt-2"
onClick={() => handleUnlockElement(id)}
>
<Lock className="w-3 h-3 mr-1" /> Unlock
</Button>
)}
{elem.unlocked && (
<Button
size="sm"
variant="outline"
className="mt-2"
onClick={() => handleAddElementalMana(id, 10)}
>
+10
</Button>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
ElementDebugSection.displayName = "ElementDebugSection";
@@ -0,0 +1,235 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
RotateCcw, AlertTriangle, Zap, Clock, Eye,
} from 'lucide-react';
import { useDebug } from '@/components/game/debug/debug-context';
import { useGameStore, useManaStore, useUIStore, useCombatStore } from '@/lib/game/stores';
import { computeMaxMana } from '@/lib/game/stores';
export function GameStateDebugSection() {
const [confirmReset, setConfirmReset] = useState(false);
const { showComponentNames, toggleComponentNames } = useDebug();
const rawMana = useManaStore((s) => s.rawMana);
const day = useGameStore((s) => s.day);
const hour = useGameStore((s) => s.hour);
const paused = useUIStore((s) => s.paused);
const togglePause = useUIStore((s) => s.togglePause);
const resetGame = useGameStore((s) => s.resetGame);
const gatherMana = useGameStore((s) => s.gatherMana);
const debugSetFloor = useCombatStore((s) => s.debugSetFloor);
const resetFloorHP = useCombatStore((s) => s.resetFloorHP);
const elements = useManaStore((s) => s.elements);
const unlockElement = useManaStore((s) => s.unlockElement);
const handleReset = () => {
if (confirmReset) {
resetGame();
setConfirmReset(false);
} else {
setConfirmReset(true);
setTimeout(() => setConfirmReset(false), 3000);
}
};
const handleAddMana = (amount: number) => {
for (let i = 0; i < amount; i++) {
gatherMana();
}
};
const getMaxMana = () => {
return computeMaxMana(
{ skills: {}, prestigeUpgrades: {}, skillUpgrades: {}, skillTiers: {} }
);
};
return (
<div className="space-y-4">
{/* Display Options */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
<Eye className="w-4 h-4" />
Display Options
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="show-component-names" className="text-sm">Show Component Names</Label>
<p className="text-xs text-gray-400">
Display component names at the top of each component for debugging
</p>
</div>
<Switch
id="show-component-names"
checked={showComponentNames}
onCheckedChange={toggleComponentNames}
/>
</div>
</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: {rawMana} / {getMaxMana() || '?'}
</div>
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={() => handleAddMana(10)}>
<Zap className="w-3 h-3 mr-1" /> +10
</Button>
<Button size="sm" variant="outline" onClick={() => handleAddMana(100)}>
<Zap className="w-3 h-3 mr-1" /> +100
</Button>
<Button size="sm" variant="outline" onClick={() => handleAddMana(1000)}>
<Zap className="w-3 h-3 mr-1" /> +1K
</Button>
<Button size="sm" variant="outline" onClick={() => handleAddMana(10000)}>
<Zap className="w-3 h-3 mr-1" /> +10K
</Button>
</div>
<Separator className="bg-gray-700" />
<div className="text-xs text-gray-400 mb-2">Fill to max:</div>
<Button
size="sm"
className="w-full bg-blue-600 hover:bg-blue-700"
onClick={() => {
const max = getMaxMana() || 100;
useManaStore.setState((s) => ({ rawMana: Math.max(s.rawMana, max) }));
}}
>
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 {day}, Hour {hour}
</div>
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={() => useGameStore.setState({ day: 1, hour: 0 })}>
Day 1
</Button>
<Button size="sm" variant="outline" onClick={() => useGameStore.setState({ day: 10, hour: 0 })}>
Day 10
</Button>
<Button size="sm" variant="outline" onClick={() => useGameStore.setState({ day: 20, hour: 0 })}>
Day 20
</Button>
<Button size="sm" variant="outline" onClick={() => useGameStore.setState({ day: 30, hour: 0 })}>
Day 30
</Button>
</div>
<Separator className="bg-gray-700" />
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={togglePause}>
{paused ? '▶ Resume' : '⏸ Pause'}
</Button>
</div>
</CardContent>
</Card>
{/* Quick Actions */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
<Zap className="w-4 h-4" />
Quick Actions
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onClick={() => {
['fire', 'water', 'air', 'earth', 'light', 'dark', 'death'].forEach(e => {
if (!elements[e]?.unlocked) {
unlockElement(e, 0);
}
});
}}
>
Unlock All Base Elements
</Button>
<Button
size="sm"
variant="outline"
onClick={() => debugSetFloor?.(100)}
>
Skip to Floor 100
</Button>
<Button
size="sm"
variant="outline"
onClick={() => resetFloorHP?.()}
>
Reset Floor HP
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
GameStateDebugSection.displayName = "GameStateDebugSection";
@@ -0,0 +1,81 @@
'use client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Bug, Wand2 } from 'lucide-react';
import { useCombatStore } from '@/lib/game/stores';
import { GOLEMS_DEF } from '@/lib/game/data/golems';
export function GolemDebugSection() {
const golemancy = useCombatStore((s) => s.golemancy);
const setEnabledGolems = useCombatStore((s) => s.setEnabledGolems);
const enabledGolems = golemancy?.enabledGolems || [];
const handleEnableAll = () => {
setEnabledGolems(Object.keys(GOLEMS_DEF));
};
const handleDisableAll = () => {
setEnabledGolems([]);
};
const handleToggleGolem = (golemId: string) => {
if (enabledGolems.includes(golemId)) {
setEnabledGolems(enabledGolems.filter(id => id !== golemId));
} else {
setEnabledGolems([...enabledGolems, golemId]);
}
};
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-orange-400 text-sm flex items-center gap-2">
<Bug className="w-4 h-4" />
Golem Debug
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={handleEnableAll}>
<Wand2 className="w-3 h-3 mr-1" /> Enable All Golems
</Button>
<Button size="sm" variant="outline" onClick={handleDisableAll}>
Disable All Golems
</Button>
</div>
<div className="text-xs text-gray-400">
Enabled: {enabledGolems.length} / {Object.keys(GOLEMS_DEF).length}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
{Object.entries(GOLEMS_DEF).map(([id, def]) => {
const isEnabled = enabledGolems.includes(id);
return (
<div
key={id}
className={`p-2 rounded border flex items-center justify-between ${
isEnabled ? 'border-orange-600/50 bg-orange-900/20' : 'border-gray-700'
}`}
>
<div>
<div className="text-sm font-medium">{def.name}</div>
<div className="text-xs text-gray-400">{def.element}</div>
</div>
<Button
size="sm"
variant={isEnabled ? 'default' : 'outline'}
onClick={() => handleToggleGolem(id)}
>
{isEnabled ? 'On' : 'Off'}
</Button>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
GolemDebugSection.displayName = "GolemDebugSection";
@@ -0,0 +1,161 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Bug } from 'lucide-react';
import { usePrestigeStore, useManaStore, useUIStore, useGameStore } from '@/lib/game/stores';
import { GUARDIANS, ELEMENTS } from '@/lib/game/constants';
export function PactDebugSection() {
const signedPacts = usePrestigeStore((s) => s.signedPacts);
const signedPactDetails = usePrestigeStore((s) => s.signedPactDetails);
const elements = useManaStore((s) => s.elements);
const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades);
const addSignedPact = usePrestigeStore((s) => s.addSignedPact);
const removePact = usePrestigeStore((s) => s.removePact);
const debugSetSignedPacts = usePrestigeStore((s) => s.debugSetSignedPacts);
const debugSetPactDetails = usePrestigeStore((s) => s.debugSetPactDetails);
const unlockElement = useManaStore((s) => s.unlockElement);
const addLog = useUIStore((s) => s.addLog);
const guardianFloors = Object.keys(GUARDIANS || {}).map(Number).sort((a, b) => a - b);
const forcePact = (floor: number) => {
const guardian = GUARDIANS[floor];
if (!guardian) return;
if (signedPacts.includes(floor)) {
addLog(`⚠️ Already signed pact with ${guardian.name}!`);
return;
}
const maxPacts = 1 + (prestigeUpgrades?.pactCapacity || 0);
if (signedPacts.length >= maxPacts) {
addLog(`⚠️ Cannot sign more pacts! Maximum: ${maxPacts}.`);
return;
}
addSignedPact(floor);
const newSignedPactDetails = {
...signedPactDetails,
[floor]: {
floor,
guardianId: guardian.element,
signedAt: { day: useGameStore.getState().day, hour: useGameStore.getState().hour },
skillLevels: {} as Record<string, number>,
},
};
debugSetPactDetails(newSignedPactDetails);
addLog(`📜 DEBUG: Pact with ${guardian.name} force-signed!`);
};
const removePactHandler = (floor: number) => {
const guardian = GUARDIANS[floor];
removePact(floor);
const newSignedPactDetails = { ...signedPactDetails };
delete newSignedPactDetails[floor];
debugSetPactDetails(newSignedPactDetails);
addLog(`📜 DEBUG: Removed pact with ${guardian?.name || 'Unknown'}!`);
};
const signAllPacts = () => {
guardianFloors.forEach((floor) => {
if (!signedPacts.includes(floor)) {
forcePact(floor);
}
});
};
const clearAllPacts = () => {
addLog(`📜 DEBUG: Cleared all pacts!`);
debugSetSignedPacts([]);
debugSetPactDetails({});
};
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-orange-400 text-sm flex items-center gap-2">
<Bug className="w-4 h-4" />
Pact Debug
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={signAllPacts}>
Sign All Pacts
</Button>
<Button size="sm" variant="destructive" onClick={clearAllPacts}>
Clear All Pacts ({signedPacts.length})
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{guardianFloors.map((floor) => {
const guardian = GUARDIANS[floor];
const isSigned = signedPacts.includes(floor);
return (
<div
key={floor}
className={`p-2 rounded border flex items-center justify-between ${
isSigned ? 'border-green-600/50 bg-green-900/20' : 'border-gray-700'
}`}
style={{ borderColor: isSigned ? undefined : guardian.color, borderWidth: '1px' }}
>
<div>
<div className="text-sm font-semibold" style={{ color: guardian.color }}>
{guardian.name}
</div>
<div className="text-xs text-gray-400">
Floor {floor} | {guardian.pact}x multiplier
</div>
<div className="text-xs text-gray-500">
Element: {ELEMENTS[guardian.element]?.name || guardian.element}
</div>
</div>
<div className="flex gap-1">
{isSigned ? (
<Button
size="sm"
variant="destructive"
onClick={() => removePactHandler(floor)}
className="text-xs"
>
Remove
</Button>
) : (
<Button
size="sm"
variant="default"
onClick={() => forcePact(floor)}
className="text-xs bg-amber-600 hover:bg-amber-700"
>
Force Sign
</Button>
)}
</div>
</div>
);
})}
</div>
<div className="text-xs text-gray-400 pt-2 border-t border-gray-700">
Signed Pacts: {signedPacts.length} |
Max Pacts: {1 + (prestigeUpgrades?.pactCapacity || 0)}
</div>
</div>
</CardContent>
</Card>
);
}
PactDebugSection.displayName = "PactDebugSection";
@@ -0,0 +1,106 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Castle, ArrowUp, Eye } from 'lucide-react';
import { useCombatStore } from '@/lib/game/stores';
export function SpireDebugSection() {
const [floorInput, setFloorInput] = useState('50');
const currentFloor = useCombatStore((s) => s.currentFloor);
const maxFloorReached = useCombatStore((s) => s.maxFloorReached);
const spireMode = useCombatStore((s) => s.spireMode);
const debugSetFloor = useCombatStore((s) => s.debugSetFloor);
const resetFloorHP = useCombatStore((s) => s.resetFloorHP);
const enterSpireMode = useCombatStore((s) => s.enterSpireMode);
const exitSpireMode = useCombatStore((s) => s.exitSpireMode);
const setMaxFloorReached = useCombatStore((s) => s.setMaxFloorReached);
const handleJumpToFloor = () => {
const floor = parseInt(floorInput, 10);
if (isNaN(floor) || floor < 1 || floor > 100) return;
debugSetFloor(floor);
setMaxFloorReached(floor);
};
const handleClearFloor = () => {
resetFloorHP();
};
const handleToggleSpireMode = () => {
if (spireMode) {
exitSpireMode();
} else {
enterSpireMode();
}
};
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-teal-400 text-sm flex items-center gap-2">
<Castle className="w-4 h-4" />
Spire Debug
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-xs text-gray-400">
Current Floor: {currentFloor} | Max Reached: {maxFloorReached} | Spire Mode: {spireMode ? 'ON' : 'OFF'}
</div>
<div className="flex gap-2 items-end">
<div className="flex-1">
<label className="text-xs text-gray-400 mb-1 block">Floor (1-100)</label>
<Input
type="number"
min={1}
max={100}
value={floorInput}
onChange={(e) => setFloorInput(e.target.value)}
className="h-8"
/>
</div>
<Button size="sm" variant="outline" onClick={handleJumpToFloor}>
<ArrowUp className="w-3 h-3 mr-1" /> Jump
</Button>
</div>
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={handleClearFloor}>
Reset Floor HP
</Button>
<Button
size="sm"
variant={spireMode ? 'default' : 'outline'}
onClick={handleToggleSpireMode}
>
<Eye className="w-3 h-3 mr-1" />
{spireMode ? 'Exit Spire Mode' : 'Enter Spire Mode'}
</Button>
</div>
<div className="flex gap-2 flex-wrap">
{[10, 25, 50, 75, 100].map((f) => (
<Button
key={f}
size="sm"
variant="outline"
onClick={() => {
setFloorInput(String(f));
debugSetFloor(f);
setMaxFloorReached(f);
}}
>
Floor {f}
</Button>
))}
</div>
</CardContent>
</Card>
);
}
SpireDebugSection.displayName = "SpireDebugSection";
+1
View File
@@ -4,3 +4,4 @@
export { DisciplinesTab } from './DisciplinesTab';
export { SpellsTab } from './SpellsTab';
export { StatsTab } from './StatsTab';
export { AchievementsTab } from './AchievementsTab';
+129
View File
@@ -0,0 +1,129 @@
import { describe, it, expect } from 'vitest';
import {
ACHIEVEMENTS,
ACHIEVEMENT_CATEGORY_COLORS,
getAchievementsByCategory,
isAchievementRevealed,
} from '../data/achievements';
describe('Achievement Definitions', () => {
it('should have 24 achievements defined', () => {
expect(Object.keys(ACHIEVEMENTS).length).toBe(24);
});
it('should have achievements across 5 categories', () => {
const byCategory = getAchievementsByCategory();
const categories = Object.keys(byCategory).sort();
expect(categories).toEqual(['combat', 'crafting', 'magic', 'progression', 'special']);
});
it('should have valid structure for every achievement', () => {
Object.values(ACHIEVEMENTS).forEach((a) => {
expect(a.id).toBeTruthy();
expect(a.name).toBeTruthy();
expect(a.desc).toBeTruthy();
expect(a.category).toBeTruthy();
expect(a.requirement.type).toBeTruthy();
expect(a.requirement.value).toBeGreaterThan(0);
// At least one reward key must be present
const rewardKeys = Object.keys(a.reward);
expect(rewardKeys.length).toBeGreaterThan(0);
});
});
it('should have category colors for all used categories', () => {
const byCategory = getAchievementsByCategory();
Object.keys(byCategory).forEach((cat) => {
expect(ACHIEVEMENT_CATEGORY_COLORS[cat]).toBeDefined();
});
});
it('should have hidden flag only on special category achievements', () => {
Object.values(ACHIEVEMENTS).forEach((a) => {
if (a.hidden) {
expect(a.category).toBe('special');
}
});
});
it('should have exactly 2 hidden achievements', () => {
const hidden = Object.values(ACHIEVEMENTS).filter((a) => a.hidden);
expect(hidden.length).toBe(2); // speedRunner and perfectionist
});
});
describe('getAchievementsByCategory', () => {
it('should group achievements correctly', () => {
const byCategory = getAchievementsByCategory();
// Combat has 6 floor + 3 damage = 9
expect(byCategory['combat'].length).toBe(9);
// Progression has 3 pacts
expect(byCategory['progression'].length).toBe(3);
// Magic has 3 spells + 3 mana = 6
expect(byCategory['magic'].length).toBe(6);
// Crafting has 3
expect(byCategory['crafting'].length).toBe(3);
// Special has 3 (2 hidden + 1 survivor)
expect(byCategory['special'].length).toBe(3);
});
it('should return a new object each call (no mutation)', () => {
const result1 = getAchievementsByCategory();
const result2 = getAchievementsByCategory();
expect(result1).not.toBe(result2);
expect(result1).toEqual(result2);
});
});
describe('isAchievementRevealed', () => {
it('should always reveal non-hidden achievements', () => {
const nonHidden = Object.values(ACHIEVEMENTS).find((a) => !a.hidden);
expect(nonHidden).toBeDefined();
expect(isAchievementRevealed(nonHidden!, 0)).toBe(true);
expect(isAchievementRevealed(nonHidden!, 999)).toBe(true);
});
it('should hide hidden achievements below 50% progress', () => {
const hidden = Object.values(ACHIEVEMENTS).find((a) => a.hidden);
expect(hidden).toBeDefined();
// At 0% progress
expect(isAchievementRevealed(hidden!, 0)).toBe(false);
// At 25% progress
expect(isAchievementRevealed(hidden!, hidden!.requirement.value * 0.25)).toBe(false);
// At exactly 49% progress
expect(isAchievementRevealed(hidden!, hidden!.requirement.value * 0.49)).toBe(false);
});
it('should reveal hidden achievements at 50% progress', () => {
const hidden = Object.values(ACHIEVEMENTS).find((a) => a.hidden);
expect(hidden).toBeDefined();
// At exactly 50%
expect(isAchievementRevealed(hidden!, hidden!.requirement.value * 0.5)).toBe(true);
// At 75%
expect(isAchievementRevealed(hidden!, hidden!.requirement.value * 0.75)).toBe(true);
// At 100%
expect(isAchievementRevealed(hidden!, hidden!.requirement.value)).toBe(true);
});
});
describe('Achievement Rewards', () => {
it('should have insight rewards on all achievements', () => {
Object.values(ACHIEVEMENTS).forEach((a) => {
expect(a.reward.insight).toBeDefined();
expect(a.reward.insight).toBeGreaterThan(0);
});
});
it('should scale rewards with difficulty', () => {
const firstBlood = ACHIEVEMENTS['firstBlood'];
const apexReached = ACHIEVEMENTS['apexReached'];
expect(apexReached.reward.insight!).toBeGreaterThan(firstBlood.reward.insight!);
});
it('should have title rewards on top-tier achievements', () => {
const topTiers = ['apexReached', 'spireMaster', 'tenThousandDamage', 'spellStorm', 'manaOcean', 'pactMaster', 'legendaryEnchanter'];
topTiers.forEach((id) => {
expect(ACHIEVEMENTS[id].reward.title).toBeDefined();
});
});
});