From 639d396f8095668544c62c8bf83f0e0790f39a23 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Tue, 19 May 2026 14:44:27 +0200 Subject: [PATCH] feat: recreate Achievements tab with category sections, progress tracking, and hidden achievement logic --- docs/circular-deps.txt | 4 +- docs/dependency-graph.json | 14 +- docs/project-structure.txt | 11 + src/app/page.tsx | 10 + src/components/game/tabs/AchievementsTab.tsx | 250 ++++++++++++++++++ .../tabs/DebugTab/AchievementDebugSection.tsx | 83 ++++++ .../tabs/DebugTab/AttunementDebugSection.tsx | 88 ++++++ .../tabs/DebugTab/DisciplineDebugSection.tsx | 135 ++++++++++ .../tabs/DebugTab/ElementDebugSection.tsx | 92 +++++++ .../tabs/DebugTab/GameStateDebugSection.tsx | 235 ++++++++++++++++ .../game/tabs/DebugTab/GolemDebugSection.tsx | 81 ++++++ .../game/tabs/DebugTab/PactDebugSection.tsx | 161 +++++++++++ .../game/tabs/DebugTab/SpireDebugSection.tsx | 106 ++++++++ src/components/game/tabs/index.ts | 1 + src/lib/game/__tests__/achievements.test.ts | 129 +++++++++ 15 files changed, 1396 insertions(+), 4 deletions(-) create mode 100644 src/components/game/tabs/AchievementsTab.tsx create mode 100644 src/components/game/tabs/DebugTab/AchievementDebugSection.tsx create mode 100644 src/components/game/tabs/DebugTab/AttunementDebugSection.tsx create mode 100644 src/components/game/tabs/DebugTab/DisciplineDebugSection.tsx create mode 100644 src/components/game/tabs/DebugTab/ElementDebugSection.tsx create mode 100644 src/components/game/tabs/DebugTab/GameStateDebugSection.tsx create mode 100644 src/components/game/tabs/DebugTab/GolemDebugSection.tsx create mode 100644 src/components/game/tabs/DebugTab/PactDebugSection.tsx create mode 100644 src/components/game/tabs/DebugTab/SpireDebugSection.tsx create mode 100644 src/lib/game/__tests__/achievements.test.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 1652803..cc69d6a 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -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 diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 149d345..7f0dec5 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -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" diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 39a5463..1d3a0c5 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -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 diff --git a/src/app/page.tsx b/src/app/page.tsx index 1677a0d..44809a5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 = () =>
Loading...
; @@ -233,6 +234,7 @@ export default function ManaLoopGame() { 📊 Stats 📚 Disciplines 📖 Grimoire + 🏆 Achievements @@ -262,6 +264,14 @@ export default function ManaLoopGame() { + + + achievements tab failed to load.}> + }> + + + + diff --git a/src/components/game/tabs/AchievementsTab.tsx b/src/components/game/tabs/AchievementsTab.tsx new file mode 100644 index 0000000..4cad98f --- /dev/null +++ b/src/components/game/tabs/AchievementsTab.tsx @@ -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 = { + combat: '⚔️ Combat', + progression: '📈 Progression', + crafting: '🔨 Crafting', + magic: '✨ Magic', + special: '🌟 Special', +}; + +function getProgressForAchievement( + achievement: AchievementDef, + progress: Record, +): 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; + 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 ( + + + {collapsed ? 'Expand ▼' : 'Collapse ▲'} + + } + /> + {!collapsed && ( + + {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 ( +
+
+ ??? + + Hidden achievement — keep progressing to reveal + +
+
+ +
+
+ ); + } + + return ( +
+
+
+ + {achievement.name} + + {isUnlocked && ( + + ✓ Unlocked + + )} +
+ + {fmt(currentProgress)} / {fmt(achievement.requirement.value)} + +
+ +

{achievement.desc}

+ +
+ + + {percent}% + +
+ +
+ Reward:{' '} + {formatReward(achievement.reward)} +
+
+ ); + })} +
+ )} +
+ ); +} + +// ─── Main Component ────────────────────────────────────────────────────────── + +export function AchievementsTab() { + const achievements = useCombatStore((s) => s.achievements); + const [mounted, setMounted] = useState(false); + const [collapsedCategories, setCollapsedCategories] = useState>({}); + + 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 ( +
+ Loading achievements… +
+ ); + } + + return ( + +
+ {/* Summary header */} + + +
+
+

+ Achievements +

+

+ Track your progress and unlock rewards +

+
+
+
+ {unlockedCount} + + /{totalAchievements} + +
+ +
+
+
+
+ + {/* Category sections */} + +
+ {categories.map((category) => ( + toggleCollapse(category)} + /> + ))} +
+
+
+
+ ); +} + +AchievementsTab.displayName = 'AchievementsTab'; diff --git a/src/components/game/tabs/DebugTab/AchievementDebugSection.tsx b/src/components/game/tabs/DebugTab/AchievementDebugSection.tsx new file mode 100644 index 0000000..32cf986 --- /dev/null +++ b/src/components/game/tabs/DebugTab/AchievementDebugSection.tsx @@ -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 ( + + + + + Achievement Debug + + + +
+ Unlocked: {unlockedCount} / {totalCount} +
+ +
+ + +
+ +
+ {Object.entries(ACHIEVEMENTS).map(([id, def]) => { + const isUnlocked = achievements?.unlocked?.includes(id); + return ( +
+
+ {def.name} + ({def.category}) +
+ {isUnlocked && ( + + )} +
+ ); + })} +
+
+
+ ); +} + +AchievementDebugSection.displayName = "AchievementDebugSection"; diff --git a/src/components/game/tabs/DebugTab/AttunementDebugSection.tsx b/src/components/game/tabs/DebugTab/AttunementDebugSection.tsx new file mode 100644 index 0000000..a1a46bf --- /dev/null +++ b/src/components/game/tabs/DebugTab/AttunementDebugSection.tsx @@ -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 ( + + + + + Attunements + + + + + {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 ( +
+
+ {def.icon} +
+
{def.name}
+ {isActive && ( +
Lv.{level} • {xp} XP
+ )} +
+
+
+ + +
+
+ ); + })} +
+
+ ); +} + +AttunementDebugSection.displayName = "AttunementDebugSection"; diff --git a/src/components/game/tabs/DebugTab/DisciplineDebugSection.tsx b/src/components/game/tabs/DebugTab/DisciplineDebugSection.tsx new file mode 100644 index 0000000..3433afa --- /dev/null +++ b/src/components/game/tabs/DebugTab/DisciplineDebugSection.tsx @@ -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 ( + + + + + Disciplines + + + +
+ + +
+
+ Active: {activeIds.length} / {concurrentLimit} +
+
+ {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 ( +
+
+
{def.name}
+
+ {isActive ? `XP: ${xp}` : 'Inactive'} +
+
+
+ + + +
+
+ ); + })} +
+
+
+ ); +} + +DisciplineDebugSection.displayName = "DisciplineDebugSection"; diff --git a/src/components/game/tabs/DebugTab/ElementDebugSection.tsx b/src/components/game/tabs/DebugTab/ElementDebugSection.tsx new file mode 100644 index 0000000..0f960e7 --- /dev/null +++ b/src/components/game/tabs/DebugTab/ElementDebugSection.tsx @@ -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 ( + + + + + Elemental Mana + + + +
+ +
+
+ {Object.entries(elements || {}).map(([id, elem]) => { + const def = ELEMENTS[id]; + return ( +
+
{def?.sym}
+
{def?.name}
+
+ {elem.current}/{elem.max} +
+ {!elem.unlocked && ( + + )} + {elem.unlocked && ( + + )} +
+ ); + })} +
+
+
+ ); +} + +ElementDebugSection.displayName = "ElementDebugSection"; diff --git a/src/components/game/tabs/DebugTab/GameStateDebugSection.tsx b/src/components/game/tabs/DebugTab/GameStateDebugSection.tsx new file mode 100644 index 0000000..ff7b77b --- /dev/null +++ b/src/components/game/tabs/DebugTab/GameStateDebugSection.tsx @@ -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 ( +
+ {/* Display Options */} + + + + + Display Options + + + +
+
+ +

+ Display component names at the top of each component for debugging +

+
+ +
+
+
+ +
+ {/* Game Reset */} + + + + + Game Reset + + + +

+ Reset all game progress and start fresh. This cannot be undone. +

+ +
+
+ + {/* Mana Debug */} + + + + + Mana Debug + + + +
+ Current: {rawMana} / {getMaxMana() || '?'} +
+
+ + + + +
+ +
Fill to max:
+ +
+
+ + {/* Time Control */} + + + + + Time Control + + + +
+ Current: Day {day}, Hour {hour} +
+
+ + + + +
+ +
+ +
+
+
+ + {/* Quick Actions */} + + + + + Quick Actions + + + +
+ + + +
+
+
+
+
+ ); +} + +GameStateDebugSection.displayName = "GameStateDebugSection"; diff --git a/src/components/game/tabs/DebugTab/GolemDebugSection.tsx b/src/components/game/tabs/DebugTab/GolemDebugSection.tsx new file mode 100644 index 0000000..0d5fe27 --- /dev/null +++ b/src/components/game/tabs/DebugTab/GolemDebugSection.tsx @@ -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 ( + + + + + Golem Debug + + + +
+ + +
+
+ Enabled: {enabledGolems.length} / {Object.keys(GOLEMS_DEF).length} +
+
+ {Object.entries(GOLEMS_DEF).map(([id, def]) => { + const isEnabled = enabledGolems.includes(id); + return ( +
+
+
{def.name}
+
{def.element}
+
+ +
+ ); + })} +
+
+
+ ); +} + +GolemDebugSection.displayName = "GolemDebugSection"; diff --git a/src/components/game/tabs/DebugTab/PactDebugSection.tsx b/src/components/game/tabs/DebugTab/PactDebugSection.tsx new file mode 100644 index 0000000..a4c4da2 --- /dev/null +++ b/src/components/game/tabs/DebugTab/PactDebugSection.tsx @@ -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, + }, + }; + 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 ( + + + + + Pact Debug + + + +
+
+ + +
+ +
+ {guardianFloors.map((floor) => { + const guardian = GUARDIANS[floor]; + const isSigned = signedPacts.includes(floor); + + return ( +
+
+
+ {guardian.name} +
+
+ Floor {floor} | {guardian.pact}x multiplier +
+
+ Element: {ELEMENTS[guardian.element]?.name || guardian.element} +
+
+
+ {isSigned ? ( + + ) : ( + + )} +
+
+ ); + })} +
+ +
+ Signed Pacts: {signedPacts.length} | + Max Pacts: {1 + (prestigeUpgrades?.pactCapacity || 0)} +
+
+
+
+ ); +} + +PactDebugSection.displayName = "PactDebugSection"; diff --git a/src/components/game/tabs/DebugTab/SpireDebugSection.tsx b/src/components/game/tabs/DebugTab/SpireDebugSection.tsx new file mode 100644 index 0000000..722829e --- /dev/null +++ b/src/components/game/tabs/DebugTab/SpireDebugSection.tsx @@ -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 ( + + + + + Spire Debug + + + +
+ Current Floor: {currentFloor} | Max Reached: {maxFloorReached} | Spire Mode: {spireMode ? 'ON' : 'OFF'} +
+ +
+
+ + setFloorInput(e.target.value)} + className="h-8" + /> +
+ +
+ +
+ + +
+ +
+ {[10, 25, 50, 75, 100].map((f) => ( + + ))} +
+
+
+ ); +} + +SpireDebugSection.displayName = "SpireDebugSection"; diff --git a/src/components/game/tabs/index.ts b/src/components/game/tabs/index.ts index 36a5d4d..f3cb11c 100644 --- a/src/components/game/tabs/index.ts +++ b/src/components/game/tabs/index.ts @@ -4,3 +4,4 @@ export { DisciplinesTab } from './DisciplinesTab'; export { SpellsTab } from './SpellsTab'; export { StatsTab } from './StatsTab'; +export { AchievementsTab } from './AchievementsTab'; diff --git a/src/lib/game/__tests__/achievements.test.ts b/src/lib/game/__tests__/achievements.test.ts new file mode 100644 index 0000000..9a6bcb8 --- /dev/null +++ b/src/lib/game/__tests__/achievements.test.ts @@ -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(); + }); + }); +});