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
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
This commit is contained in:
@@ -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";
|
||||
@@ -4,3 +4,4 @@
|
||||
export { DisciplinesTab } from './DisciplinesTab';
|
||||
export { SpellsTab } from './SpellsTab';
|
||||
export { StatsTab } from './StatsTab';
|
||||
export { AchievementsTab } from './AchievementsTab';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user