Initial commit

This commit is contained in:
Z User
2026-03-28 06:07:39 +00:00
commit 9566f44652
134 changed files with 27218 additions and 0 deletions

5
src/app/api/route.ts Executable file
View File

@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Hello, world!" });
}

300
src/app/globals.css Executable file
View File

@@ -0,0 +1,300 @@
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@400;500&display=swap');
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: #060811;
--foreground: #c8d8f8;
--card: #0C1020;
--card-foreground: #c8d8f8;
--popover: #111628;
--popover-foreground: #c8d8f8;
--primary: #3B6FE8;
--primary-foreground: #ffffff;
--secondary: #1e2a45;
--secondary-foreground: #c8d8f8;
--muted: #181f35;
--muted-foreground: #7a92c0;
--accent: #2a3a60;
--accent-foreground: #c8d8f8;
--destructive: #C0392B;
--border: #1e2a45;
--input: #1e2a45;
--ring: #3B6FE8;
--chart-1: #FF6B35;
--chart-2: #4ECDC4;
--chart-3: #9B59B6;
--chart-4: #2ECC71;
--chart-5: #FFD700;
--sidebar: #0C1020;
--sidebar-foreground: #c8d8f8;
--sidebar-primary: #D4A843;
--sidebar-primary-foreground: #0C1020;
--sidebar-accent: #1e2a45;
--sidebar-accent-foreground: #c8d8f8;
--sidebar-border: #1e2a45;
--sidebar-ring: #D4A843;
/* Game-specific colors */
--game-bg: #060811;
--game-bg1: #0C1020;
--game-bg2: #111628;
--game-bg3: #181f35;
--game-border: #1e2a45;
--game-border2: #2a3a60;
--game-text: #c8d8f8;
--game-text2: #7a92c0;
--game-text3: #4a5f8a;
--game-gold: #D4A843;
--game-gold2: #A87830;
--game-purple: #7C5CBF;
--game-purpleL: #A07EE0;
--game-accent: #3B6FE8;
--game-accentL: #5B8FFF;
--game-danger: #C0392B;
--game-success: #27AE60;
}
.dark {
--background: #060811;
--foreground: #c8d8f8;
--card: #0C1020;
--card-foreground: #c8d8f8;
--popover: #111628;
--popover-foreground: #c8d8f8;
--primary: #5B8FFF;
--primary-foreground: #ffffff;
--secondary: #1e2a45;
--secondary-foreground: #c8d8f8;
--muted: #181f35;
--muted-foreground: #7a92c0;
--accent: #2a3a60;
--accent-foreground: #c8d8f8;
--destructive: #C0392B;
--border: #1e2a45;
--input: #1e2a45;
--ring: #5B8FFF;
--chart-1: #FF6B35;
--chart-2: #4ECDC4;
--chart-3: #9B59B6;
--chart-4: #2ECC71;
--chart-5: #FFD700;
--sidebar: #0C1020;
--sidebar-foreground: #c8d8f8;
--sidebar-primary: #D4A843;
--sidebar-primary-foreground: #0C1020;
--sidebar-accent: #1e2a45;
--sidebar-accent-foreground: #c8d8f8;
--sidebar-border: #1e2a45;
--sidebar-ring: #D4A843;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
font-family: 'Crimson Text', Georgia, serif;
}
}
/* Game-specific styles */
.game-root {
font-family: 'Crimson Text', Georgia, serif;
background: var(--game-bg);
color: var(--game-text);
min-height: 100vh;
display: flex;
flex-direction: column;
background-image:
radial-gradient(ellipse at 20% 10%, #0D1535 0%, transparent 50%),
radial-gradient(ellipse at 80% 90%, #0A0A20 0%, transparent 50%),
repeating-linear-gradient(0deg, transparent, transparent 40px, rgba(30,42,69,0.15) 40px, rgba(30,42,69,0.15) 41px),
repeating-linear-gradient(90deg, transparent, transparent 40px, rgba(30,42,69,0.15) 40px, rgba(30,42,69,0.15) 41px);
}
.game-title {
font-family: 'Cinzel', serif;
background: linear-gradient(135deg, var(--game-gold) 0%, var(--game-purpleL) 50%, var(--game-accentL) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.game-panel-title {
font-family: 'Cinzel', serif;
letter-spacing: 2px;
text-transform: uppercase;
}
.game-mono {
font-family: 'JetBrains Mono', monospace;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--game-border2);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--game-purple);
}
/* Mana bar animation */
@keyframes mana-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.mana-bar-animated {
animation: mana-pulse 2s ease-in-out infinite;
}
/* Glow effects */
.glow-gold {
box-shadow: 0 0 15px rgba(212, 168, 67, 0.4);
}
.glow-purple {
box-shadow: 0 0 15px rgba(124, 92, 191, 0.4);
}
.glow-accent {
box-shadow: 0 0 15px rgba(60, 111, 232, 0.4);
}
/* Button hover effects */
.btn-game {
transition: all 0.2s ease;
}
.btn-game:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Card hover effects */
.card-game {
transition: all 0.2s ease;
}
.card-game:hover {
border-color: var(--game-border2);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
/* Element pill styles */
.elem-pill {
transition: all 0.15s ease;
}
.elem-pill:hover {
filter: brightness(1.2);
transform: scale(1.02);
}
/* Calendar day styles */
.day-cell {
transition: all 0.15s ease;
}
.day-current {
box-shadow: 0 0 8px rgba(60, 111, 232, 0.5);
}
.day-incursion {
border-color: rgba(192, 57, 43, 0.6) !important;
}
/* Game over overlay */
.game-overlay {
backdrop-filter: blur(8px);
background: rgba(6, 8, 17, 0.92);
}
/* Pact badge */
.pact-badge {
box-shadow: 0 0 10px rgba(212, 168, 67, 0.3);
}
/* Spell card active */
.spell-active {
border-color: var(--game-gold);
background: rgba(212, 168, 67, 0.1);
}
/* Tab active */
.tab-active {
color: var(--game-gold);
border-bottom-color: var(--game-gold);
}
/* Skill dot filled */
.skill-dot-filled {
background: var(--game-purpleL);
box-shadow: 0 0 6px rgba(160, 126, 224, 0.5);
}
/* Log entry */
.log-entry-new {
color: var(--game-text);
}
.log-entry-old {
color: var(--game-text2);
}

53
src/app/layout.tsx Executable file
View File

@@ -0,0 +1,53 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/toaster";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Z.ai Code Scaffold - AI-Powered Development",
description: "Modern Next.js scaffold optimized for AI-powered development with Z.ai. Built with TypeScript, Tailwind CSS, and shadcn/ui.",
keywords: ["Z.ai", "Next.js", "TypeScript", "Tailwind CSS", "shadcn/ui", "AI development", "React"],
authors: [{ name: "Z.ai Team" }],
icons: {
icon: "https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
},
openGraph: {
title: "Z.ai Code Scaffold",
description: "AI-powered development with modern React stack",
url: "https://chat.z.ai",
siteName: "Z.ai",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "Z.ai Code Scaffold",
description: "AI-powered development with modern React stack",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
>
{children}
<Toaster />
</body>
</html>
);
}

439
src/app/page.tsx Executable file
View File

@@ -0,0 +1,439 @@
'use client';
import { useEffect, useState } from 'react';
import { useGameStore, useGameLoop, fmt, fmtDec, getFloorElement, computeMaxMana, computeRegen, computeClickMana, getMeditationBonus, getIncursionStrength, canAffordSpellCost } from '@/lib/game/store';
import { getActiveEquipmentSpells, getTotalDPS } from '@/lib/game/computed-stats';
import { getDamageBreakdown } from '@/lib/game/computed-stats';
import { ELEMENTS, GUARDIANS, SPELLS_DEF, PRESTIGE_DEF, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
import { SKILL_EVOLUTION_PATHS, getTierMultiplier } from '@/lib/game/skill-evolution';
import { getUnifiedEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
import { formatHour } from '@/lib/game/formatting';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { RotateCcw } from 'lucide-react';
import { CraftingTab, SpireTab, SpellsTab, LabTab, SkillsTab, StatsTab, EquipmentTab, AttunementsTab, DebugTab } from '@/components/game/tabs';
import { ActionButtons, CalendarDisplay, ManaDisplay, TimeDisplay } from '@/components/game';
import { LootInventoryDisplay } from '@/components/game/LootInventory';
import { AchievementsDisplay } from '@/components/game/AchievementsDisplay';
export default function ManaLoopGame() {
const [activeTab, setActiveTab] = useState('spire');
const [isGathering, setIsGathering] = useState(false);
// Game store
const store = useGameStore();
const gameLoop = useGameLoop();
// Computed effects from upgrades and equipment
const upgradeEffects = getUnifiedEffects(store);
// Derived stats
const maxMana = computeMaxMana(store, upgradeEffects);
const baseRegen = computeRegen(store, upgradeEffects);
const clickMana = computeClickMana(store);
const floorElem = getFloorElement(store.currentFloor);
const floorElemDef = ELEMENTS[floorElem];
const isGuardianFloor = !!GUARDIANS[store.currentFloor];
const currentGuardian = GUARDIANS[store.currentFloor];
const meditationMultiplier = getMeditationBonus(store.meditateTicks, store.skills, upgradeEffects.meditationEfficiency);
const incursionStrength = getIncursionStrength(store.day, store.hour);
const studySpeedMult = getStudySpeedMultiplier(store.skills);
const studyCostMult = getStudyCostMultiplier(store.skills);
// Effective regen with incursion penalty
const effectiveRegenWithSpecials = baseRegen * (1 - incursionStrength);
// Mana Cascade bonus
const manaCascadeBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_CASCADE)
? Math.floor(maxMana / 100) * 0.1
: 0;
// Effective regen
const effectiveRegen = (effectiveRegenWithSpecials + manaCascadeBonus) * meditationMultiplier;
// Get all active spells from equipment
const activeEquipmentSpells = getActiveEquipmentSpells(store.equippedInstances, store.equipmentInstances);
// Compute total DPS
const totalDPS = getTotalDPS(store, upgradeEffects, floorElem);
// Auto-gather while holding
useEffect(() => {
if (!isGathering) return;
let lastGatherTime = 0;
const minGatherInterval = 100;
let animationFrameId: number;
const gatherLoop = (timestamp: number) => {
if (timestamp - lastGatherTime >= minGatherInterval) {
store.gatherMana();
lastGatherTime = timestamp;
}
animationFrameId = requestAnimationFrame(gatherLoop);
};
animationFrameId = requestAnimationFrame(gatherLoop);
return () => cancelAnimationFrame(animationFrameId);
}, [isGathering, store]);
// Handle gather button events
const handleGatherStart = () => {
setIsGathering(true);
store.gatherMana();
};
const handleGatherEnd = () => {
setIsGathering(false);
};
// Start game loop
useEffect(() => {
const cleanup = gameLoop.start();
return cleanup;
}, [gameLoop]);
// Check if spell can be cast
const canCastSpell = (spellId: string): boolean => {
const spell = SPELLS_DEF[spellId];
if (!spell) return false;
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
};
// Game Over Screen
if (store.gameOver) {
return (
<div className="fixed inset-0 game-overlay flex items-center justify-center z-50">
<Card className="bg-gray-900 border-gray-600 max-w-md w-full mx-4 shadow-2xl">
<CardHeader>
<CardTitle className={`text-3xl text-center game-title ${store.victory ? 'text-amber-400' : 'text-red-400'}`}>
{store.victory ? 'VICTORY!' : 'LOOP ENDS'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-center text-gray-400">
{store.victory
? 'The Awakened One falls! Your power echoes through eternity.'
: 'The time loop resets... but you remember.'}
</p>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-gray-800 rounded">
<div className="text-xl font-bold text-amber-400 game-mono">{fmt(store.loopInsight)}</div>
<div className="text-xs text-gray-400">Insight Gained</div>
</div>
<div className="p-3 bg-gray-800 rounded">
<div className="text-xl font-bold text-blue-400 game-mono">{store.maxFloorReached}</div>
<div className="text-xs text-gray-400">Best Floor</div>
</div>
<div className="p-3 bg-gray-800 rounded">
<div className="text-xl font-bold text-purple-400 game-mono">{store.signedPacts.length}</div>
<div className="text-xs text-gray-400">Pacts Signed</div>
</div>
<div className="p-3 bg-gray-800 rounded">
<div className="text-xl font-bold text-green-400 game-mono">{store.loopCount + 1}</div>
<div className="text-xs text-gray-400">Total Loops</div>
</div>
</div>
<Button
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
size="lg"
onClick={() => store.startNewLoop()}
>
Begin New Loop
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<TooltipProvider>
<div className="game-root min-h-screen flex flex-col">
{/* Header */}
<header className="sticky top-0 z-50 bg-gradient-to-b from-gray-900 to-gray-900/80 border-b border-gray-700 px-4 py-2">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold game-title tracking-wider">MANA LOOP</h1>
<div className="flex items-center gap-4">
<TimeDisplay
day={store.day}
hour={store.hour}
isPaused={store.isPaused}
togglePause={store.togglePause}
/>
</div>
</div>
</header>
{/* Main Content */}
<main className="flex-1 flex flex-col md:flex-row gap-4 p-4">
{/* Left Panel - Mana & Actions */}
<div className="md:w-80 space-y-4 flex-shrink-0">
{/* Mana Display */}
<ManaDisplay
rawMana={store.rawMana}
maxMana={maxMana}
effectiveRegen={effectiveRegen}
meditationMultiplier={meditationMultiplier}
clickMana={clickMana}
isGathering={isGathering}
onGatherStart={handleGatherStart}
onGatherEnd={handleGatherEnd}
elements={store.elements}
/>
{/* Action Buttons */}
<ActionButtons
currentAction={store.currentAction}
designProgress={store.designProgress}
preparationProgress={store.preparationProgress}
applicationProgress={store.applicationProgress}
setAction={store.setAction}
/>
{/* Calendar */}
<CalendarDisplay
day={store.day}
hour={store.hour}
incursionStrength={incursionStrength}
/>
{/* Loot Inventory */}
<LootInventoryDisplay
inventory={store.lootInventory}
elements={store.elements}
equipmentInstances={store.equipmentInstances}
onDeleteMaterial={store.deleteMaterial}
onDeleteEquipment={store.deleteEquipmentInstance}
/>
{/* Achievements */}
<AchievementsDisplay
achievements={store.achievements}
gameState={{
maxFloorReached: store.maxFloorReached,
totalManaGathered: store.totalManaGathered,
signedPacts: store.signedPacts,
totalSpellsCast: store.totalSpellsCast,
totalDamageDealt: store.totalDamageDealt,
totalCraftsCompleted: store.totalCraftsCompleted,
combo: store.combo,
}}
/>
</div>
{/* Right Panel - Tabs */}
<div className="flex-1 min-w-0">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="flex flex-wrap gap-1 w-full mb-4 h-auto">
<TabsTrigger value="spire" className="text-xs px-2 py-1"> Spire</TabsTrigger>
<TabsTrigger value="attunements" className="text-xs px-2 py-1"> Attune</TabsTrigger>
<TabsTrigger value="skills" className="text-xs px-2 py-1">📚 Skills</TabsTrigger>
<TabsTrigger value="spells" className="text-xs px-2 py-1">🔮 Spells</TabsTrigger>
<TabsTrigger value="equipment" className="text-xs px-2 py-1">🛡 Gear</TabsTrigger>
<TabsTrigger value="crafting" className="text-xs px-2 py-1">🔧 Craft</TabsTrigger>
<TabsTrigger value="lab" className="text-xs px-2 py-1">🔬 Lab</TabsTrigger>
<TabsTrigger value="stats" className="text-xs px-2 py-1">📊 Stats</TabsTrigger>
<TabsTrigger value="debug" className="text-xs px-2 py-1">🔧 Debug</TabsTrigger>
<TabsTrigger value="grimoire" className="text-xs px-2 py-1">📖 Grimoire</TabsTrigger>
</TabsList>
<TabsContent value="spire">
<SpireTab store={store} />
</TabsContent>
<TabsContent value="attunements">
<AttunementsTab store={store} />
</TabsContent>
<TabsContent value="skills">
<SkillsTab store={store} />
</TabsContent>
<TabsContent value="spells">
<SpellsTab store={store} />
</TabsContent>
<TabsContent value="equipment">
<EquipmentTab store={store} />
</TabsContent>
<TabsContent value="crafting">
<CraftingTab store={store} />
</TabsContent>
<TabsContent value="lab">
<LabTab store={store} />
</TabsContent>
<TabsContent value="stats">
<StatsTab
store={store}
upgradeEffects={upgradeEffects}
maxMana={maxMana}
baseRegen={baseRegen}
clickMana={clickMana}
meditationMultiplier={meditationMultiplier}
effectiveRegen={effectiveRegen}
incursionStrength={incursionStrength}
manaCascadeBonus={manaCascadeBonus}
studySpeedMult={studySpeedMult}
studyCostMult={studyCostMult}
/>
</TabsContent>
<TabsContent value="grimoire">
{renderGrimoireTab()}
</TabsContent>
<TabsContent value="debug">
<DebugTab store={store} />
</TabsContent>
</Tabs>
</div>
</main>
</div>
</TooltipProvider>
);
// Grimoire Tab (Prestige)
function renderGrimoireTab() {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Current Status */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Loop Status</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
<div className="text-xs text-gray-400">Loops Completed</div>
</div>
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
<div className="text-xs text-gray-400">Current Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
<div className="text-xs text-gray-400">Total Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-green-400 game-mono">{store.memorySlots}</div>
<div className="text-xs text-gray-400">Memory Slots</div>
</div>
</div>
</CardContent>
</Card>
{/* Signed Pacts */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Signed Pacts</CardTitle>
</CardHeader>
<CardContent>
{store.signedPacts.length === 0 ? (
<div className="text-gray-500 text-sm">No pacts signed yet. Defeat guardians to earn pacts.</div>
) : (
<div className="space-y-2">
{store.signedPacts.map((floor) => {
const guardian = GUARDIANS[floor];
if (!guardian) return null;
return (
<div
key={floor}
className="flex items-center justify-between p-2 rounded border"
style={{ borderColor: guardian.color, backgroundColor: `${guardian.color}15` }}
>
<div>
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
{guardian.name}
</div>
<div className="text-xs text-gray-400">Floor {floor}</div>
</div>
<Badge className="bg-amber-900/50 text-amber-300">
{guardian.pact}x multiplier
</Badge>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Prestige Upgrades */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Insight Upgrades (Permanent)</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{Object.entries(PRESTIGE_DEF).map(([id, def]) => {
const level = store.prestigeUpgrades[id] || 0;
const maxed = level >= def.max;
const canBuy = !maxed && store.insight >= def.cost;
return (
<div
key={id}
className="p-3 rounded border border-gray-700 bg-gray-800/50"
>
<div className="flex items-center justify-between mb-2">
<div className="font-semibold text-amber-400 text-sm">{def.name}</div>
<Badge variant="outline" className="text-xs">
{level}/{def.max}
</Badge>
</div>
<div className="text-xs text-gray-400 italic mb-2">{def.desc}</div>
<Button
size="sm"
variant={canBuy ? 'default' : 'outline'}
className="w-full"
disabled={!canBuy}
onClick={() => store.doPrestige(id)}
>
{maxed ? 'Maxed' : `Upgrade (${fmt(def.cost)} insight)`}
</Button>
</div>
);
})}
</div>
{/* Reset Game Button */}
<div className="mt-4 pt-4 border-t border-gray-700">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400">Reset All Progress</div>
<div className="text-xs text-gray-500">Clear all data and start fresh</div>
</div>
<Button
size="sm"
variant="outline"
className="border-red-600/50 text-red-400 hover:bg-red-900/20"
onClick={() => {
if (confirm('Are you sure you want to reset ALL progress? This cannot be undone!')) {
store.resetGame();
}
}}
>
<RotateCcw className="w-4 h-4 mr-1" />
Reset
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
}
// Import TooltipProvider
import { TooltipProvider } from '@/components/ui/tooltip';

View File

@@ -0,0 +1,175 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Progress } from '@/components/ui/progress';
import { Trophy, Lock, CheckCircle, ChevronDown, ChevronUp } from 'lucide-react';
import type { AchievementState } from '@/lib/game/types';
import { ACHIEVEMENTS, ACHIEVEMENT_CATEGORY_COLORS, getAchievementsByCategory, isAchievementRevealed } from '@/lib/game/data/achievements';
import { GameState } from '@/lib/game/types';
interface AchievementsProps {
achievements: AchievementState;
gameState: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'totalSpellsCast' | 'totalDamageDealt' | 'totalCraftsCompleted' | 'combo'>;
}
export function AchievementsDisplay({ achievements, gameState }: AchievementsProps) {
const [expandedCategory, setExpandedCategory] = useState<string | null>('combat');
const categories = getAchievementsByCategory();
const unlockedCount = achievements.unlocked.length;
const totalCount = Object.keys(ACHIEVEMENTS).length;
// Calculate progress for each achievement
const getProgress = (achievementId: string): number => {
const achievement = ACHIEVEMENTS[achievementId];
if (!achievement) return 0;
if (achievements.unlocked.includes(achievementId)) return achievement.requirement.value;
const { type, subType } = achievement.requirement;
switch (type) {
case 'floor':
if (subType === 'noPacts') {
return gameState.maxFloorReached >= achievement.requirement.value && gameState.signedPacts.length === 0
? achievement.requirement.value
: gameState.maxFloorReached;
}
return gameState.maxFloorReached;
case 'combo':
return gameState.combo?.maxCombo || 0;
case 'spells':
return gameState.totalSpellsCast || 0;
case 'damage':
return gameState.totalDamageDealt || 0;
case 'mana':
return gameState.totalManaGathered || 0;
case 'pact':
return gameState.signedPacts.length;
case 'craft':
return gameState.totalCraftsCompleted || 0;
default:
return achievements.progress[achievementId] || 0;
}
};
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
<Trophy className="w-4 h-4" />
Achievements
<Badge className="ml-auto bg-amber-900/50 text-amber-300">
{unlockedCount} / {totalCount}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-64">
<div className="space-y-2">
{Object.entries(categories).map(([category, categoryAchievements]) => (
<div key={category} className="space-y-1">
<Button
variant="ghost"
size="sm"
className="w-full justify-between text-xs"
onClick={() => setExpandedCategory(expandedCategory === category ? null : category)}
>
<span style={{ color: ACHIEVEMENT_CATEGORY_COLORS[category] }}>
{category.charAt(0).toUpperCase() + category.slice(1)}
</span>
<span className="text-gray-500">
{categoryAchievements.filter(a => achievements.unlocked.includes(a.id)).length} / {categoryAchievements.length}
</span>
{expandedCategory === category ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</Button>
{expandedCategory === category && (
<div className="pl-2 space-y-2">
{categoryAchievements.map((achievement) => {
const isUnlocked = achievements.unlocked.includes(achievement.id);
const progress = getProgress(achievement.id);
const isRevealed = isAchievementRevealed(achievement, progress);
const progressPercent = Math.min(100, (progress / achievement.requirement.value) * 100);
if (!isRevealed && !isUnlocked) {
return (
<div key={achievement.id} className="p-2 rounded bg-gray-800/30 border border-gray-700">
<div className="flex items-center gap-2 text-gray-500">
<Lock className="w-4 h-4" />
<span className="text-sm">???</span>
</div>
</div>
);
}
return (
<div
key={achievement.id}
className={`p-2 rounded border ${
isUnlocked
? 'bg-amber-900/20 border-amber-600/50'
: 'bg-gray-800/30 border-gray-700'
}`}
>
<div className="flex items-start justify-between mb-1">
<div className="flex items-center gap-2">
{isUnlocked ? (
<CheckCircle className="w-4 h-4 text-amber-400" />
) : (
<Trophy className="w-4 h-4 text-gray-500" />
)}
<span className={`text-sm font-semibold ${isUnlocked ? 'text-amber-300' : 'text-gray-300'}`}>
{achievement.name}
</span>
</div>
{achievement.reward.title && isUnlocked && (
<Badge className="text-xs bg-purple-900/50 text-purple-300">
Title
</Badge>
)}
</div>
<div className="text-xs text-gray-400 mb-2">
{achievement.desc}
</div>
{!isUnlocked && (
<div className="space-y-1">
<Progress value={progressPercent} className="h-1 bg-gray-700" />
<div className="flex justify-between text-xs text-gray-500">
<span>{progress.toLocaleString()} / {achievement.requirement.value.toLocaleString()}</span>
<span>{progressPercent.toFixed(0)}%</span>
</div>
</div>
)}
{isUnlocked && achievement.reward && (
<div className="text-xs text-amber-400/70">
Reward:
{achievement.reward.insight && ` +${achievement.reward.insight} Insight`}
{achievement.reward.manaBonus && ` +${achievement.reward.manaBonus} Max Mana`}
{achievement.reward.damageBonus && ` +${(achievement.reward.damageBonus * 100).toFixed(0)}% Damage`}
{achievement.reward.title && ` "${achievement.reward.title}"`}
</div>
)}
</div>
);
})}
</div>
)}
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import { Button } from '@/components/ui/button';
import { Sparkles, Swords, BookOpen, Target, FlaskConical } from 'lucide-react';
import type { GameAction } from '@/lib/game/types';
interface ActionButtonsProps {
currentAction: GameAction;
designProgress: { progress: number; required: number } | null;
preparationProgress: { progress: number; required: number } | null;
applicationProgress: { progress: number; required: number } | null;
setAction: (action: GameAction) => void;
}
export function ActionButtons({
currentAction,
designProgress,
preparationProgress,
applicationProgress,
setAction,
}: ActionButtonsProps) {
const actions: { id: GameAction; label: string; icon: typeof Swords }[] = [
{ id: 'meditate', label: 'Meditate', icon: Sparkles },
{ id: 'climb', label: 'Climb', icon: Swords },
{ id: 'study', label: 'Study', icon: BookOpen },
];
const hasDesignProgress = designProgress !== null;
const hasPrepProgress = preparationProgress !== null;
const hasAppProgress = applicationProgress !== null;
return (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-2">
{actions.map(({ id, label, icon: Icon }) => (
<Button
key={id}
variant={currentAction === id ? 'default' : 'outline'}
size="sm"
className={`h-9 ${currentAction === id ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
onClick={() => setAction(id)}
>
<Icon className="w-4 h-4 mr-1" />
{label}
</Button>
))}
</div>
{/* Crafting actions row - shown when there's active crafting progress */}
{(hasDesignProgress || hasPrepProgress || hasAppProgress) && (
<div className="grid grid-cols-3 gap-2">
<Button
variant={currentAction === 'design' ? 'default' : 'outline'}
size="sm"
disabled={!hasDesignProgress}
className={`h-9 ${currentAction === 'design' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
onClick={() => hasDesignProgress && setAction('design')}
>
<Target className="w-4 h-4 mr-1" />
Design
</Button>
<Button
variant={currentAction === 'prepare' ? 'default' : 'outline'}
size="sm"
disabled={!hasPrepProgress}
className={`h-9 ${currentAction === 'prepare' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
onClick={() => hasPrepProgress && setAction('prepare')}
>
<FlaskConical className="w-4 h-4 mr-1" />
Prepare
</Button>
<Button
variant={currentAction === 'enchant' ? 'default' : 'outline'}
size="sm"
disabled={!hasAppProgress}
className={`h-9 ${currentAction === 'enchant' ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'}`}
onClick={() => hasAppProgress && setAction('enchant')}
>
<Sparkles className="w-4 h-4 mr-1" />
Enchant
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { MAX_DAY, INCURSION_START_DAY } from '@/lib/game/constants';
interface CalendarDisplayProps {
day: number;
hour: number;
incursionStrength?: number;
}
export function CalendarDisplay({ day }: CalendarDisplayProps) {
const days: React.ReactElement[] = [];
for (let d = 1; d <= MAX_DAY; d++) {
let dayClass = 'w-6 h-6 sm:w-7 sm:h-7 rounded text-xs flex items-center justify-center font-mono border transition-all ';
if (d < day) {
dayClass += 'bg-blue-900/30 border-blue-800/50 text-blue-400';
} else if (d === day) {
dayClass += 'bg-blue-600/40 border-blue-500 text-blue-300 shadow-lg shadow-blue-500/30';
} else {
dayClass += 'bg-gray-800/30 border-gray-700/50 text-gray-500';
}
if (d >= INCURSION_START_DAY) {
dayClass += ' border-red-600/50';
}
days.push(
<Tooltip key={d}>
<TooltipTrigger asChild>
<div className={dayClass}>
{d}
</div>
</TooltipTrigger>
<TooltipContent>
<p>Day {d}</p>
{d >= INCURSION_START_DAY && <p className="text-red-400">Incursion Active</p>}
</TooltipContent>
</Tooltip>
);
}
return (
<div className="grid grid-cols-7 sm:grid-cols-7 md:grid-cols-14 gap-1">
{days}
</div>
);
}

View File

@@ -0,0 +1,143 @@
'use client';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Zap, Flame, Sparkles } from 'lucide-react';
import type { ComboState } from '@/lib/game/types';
import { ELEMENTS } from '@/lib/game/constants';
interface ComboMeterProps {
combo: ComboState;
isClimbing: boolean;
}
export function ComboMeter({ combo, isClimbing }: ComboMeterProps) {
const comboPercent = Math.min(100, combo.count);
const multiplierPercent = Math.min(100, ((combo.multiplier - 1) / 2) * 100); // Max 300% = 200% bonus
// Combo tier names
const getComboTier = (count: number): { name: string; color: string } => {
if (count >= 100) return { name: 'LEGENDARY', color: 'text-amber-400' };
if (count >= 75) return { name: 'Master', color: 'text-purple-400' };
if (count >= 50) return { name: 'Expert', color: 'text-blue-400' };
if (count >= 25) return { name: 'Adept', color: 'text-green-400' };
if (count >= 10) return { name: 'Novice', color: 'text-cyan-400' };
return { name: 'Building...', color: 'text-gray-400' };
};
const tier = getComboTier(combo.count);
const hasElementChain = combo.elementChain.length === 3 && new Set(combo.elementChain).size === 3;
if (!isClimbing && combo.count === 0) {
return null;
}
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
<Zap className="w-4 h-4" />
Combo Meter
{combo.count >= 10 && (
<Badge className={`ml-auto ${tier.color} bg-gray-800`}>
{tier.name}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* Combo Count */}
<div className="space-y-1">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Hits</span>
<span className={`font-bold ${tier.color}`}>
{combo.count}
{combo.maxCombo > combo.count && (
<span className="text-gray-500 text-xs ml-2">max: {combo.maxCombo}</span>
)}
</span>
</div>
<Progress
value={comboPercent}
className="h-2 bg-gray-800"
/>
</div>
{/* Multiplier */}
<div className="space-y-1">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Multiplier</span>
<span className="font-bold text-amber-400">
{combo.multiplier.toFixed(2)}x
</span>
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${multiplierPercent}%`,
background: `linear-gradient(90deg, #F59E0B, #EF4444)`,
}}
/>
</div>
</div>
{/* Element Chain */}
{combo.elementChain.length > 0 && (
<div className="space-y-1">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Element Chain</span>
{hasElementChain && (
<span className="text-green-400 text-xs">+25% bonus!</span>
)}
</div>
<div className="flex gap-1">
{combo.elementChain.map((elem, i) => {
const elemDef = ELEMENTS[elem];
return (
<div
key={i}
className="w-8 h-8 rounded border flex items-center justify-center text-xs"
style={{
borderColor: elemDef?.color || '#60A5FA',
backgroundColor: `${elemDef?.color}20`,
color: elemDef?.color || '#60A5FA',
}}
>
{elemDef?.sym || '?'}
</div>
);
})}
{/* Empty slots */}
{Array.from({ length: 3 - combo.elementChain.length }).map((_, i) => (
<div
key={`empty-${i}`}
className="w-8 h-8 rounded border border-gray-700 bg-gray-800/50 flex items-center justify-center text-gray-600"
>
?
</div>
))}
</div>
</div>
)}
{/* Decay Warning */}
{isClimbing && combo.count > 0 && combo.decayTimer <= 3 && (
<div className="text-xs text-red-400 flex items-center gap-1">
<Flame className="w-3 h-3" />
Combo decaying soon!
</div>
)}
{/* Not climbing warning */}
{!isClimbing && combo.count > 0 && (
<div className="text-xs text-amber-400 flex items-center gap-1">
<Sparkles className="w-3 h-3" />
Resume climbing to maintain combo
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,161 @@
'use client';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Target, FlaskConical, Sparkles, Play, Pause, X } from 'lucide-react';
import { fmt } from '@/lib/game/store';
import { formatStudyTime } from '@/lib/game/formatting';
import type { EquipmentInstance, EnchantmentDesign } from '@/lib/game/types';
interface CraftingProgressProps {
designProgress: { designId: string; progress: number; required: number } | null;
preparationProgress: { equipmentInstanceId: string; progress: number; required: number; manaCostPaid: number } | null;
applicationProgress: { equipmentInstanceId: string; designId: string; progress: number; required: number; manaPerHour: number; paused: boolean } | null;
equipmentInstances: Record<string, EquipmentInstance>;
enchantmentDesigns: EnchantmentDesign[];
cancelDesign: () => void;
cancelPreparation: () => void;
pauseApplication: () => void;
resumeApplication: () => void;
cancelApplication: () => void;
}
export function CraftingProgress({
designProgress,
preparationProgress,
applicationProgress,
equipmentInstances,
enchantmentDesigns,
cancelDesign,
cancelPreparation,
pauseApplication,
resumeApplication,
cancelApplication,
}: CraftingProgressProps) {
const progressSections: React.ReactNode[] = [];
// Design progress
if (designProgress) {
const progressPct = Math.min(100, (designProgress.progress / designProgress.required) * 100);
progressSections.push(
<div key="design" className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Target className="w-4 h-4 text-cyan-400" />
<span className="text-sm font-semibold text-cyan-300">
Designing Enchantment
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={cancelDesign}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(designProgress.progress)} / {formatStudyTime(designProgress.required)}</span>
<span>Design Time</span>
</div>
</div>
);
}
// Preparation progress
if (preparationProgress) {
const progressPct = Math.min(100, (preparationProgress.progress / preparationProgress.required) * 100);
const instance = equipmentInstances[preparationProgress.equipmentInstanceId];
progressSections.push(
<div key="prepare" className="p-3 rounded border border-green-600/50 bg-green-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FlaskConical className="w-4 h-4 text-green-400" />
<span className="text-sm font-semibold text-green-300">
Preparing {instance?.name || 'Equipment'}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={cancelPreparation}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(preparationProgress.progress)} / {formatStudyTime(preparationProgress.required)}</span>
<span>Mana spent: {fmt(preparationProgress.manaCostPaid)}</span>
</div>
</div>
);
}
// Application progress
if (applicationProgress) {
const progressPct = Math.min(100, (applicationProgress.progress / applicationProgress.required) * 100);
const instance = equipmentInstances[applicationProgress.equipmentInstanceId];
const design = enchantmentDesigns.find(d => d.id === applicationProgress.designId);
progressSections.push(
<div key="enchant" className="p-3 rounded border border-amber-600/50 bg-amber-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-400" />
<span className="text-sm font-semibold text-amber-300">
Enchanting {instance?.name || 'Equipment'}
</span>
</div>
<div className="flex gap-1">
{applicationProgress.paused ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-green-400 hover:text-green-300"
onClick={resumeApplication}
>
<Play className="w-4 h-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-yellow-400 hover:text-yellow-300"
onClick={pauseApplication}
>
<Pause className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={cancelApplication}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(applicationProgress.progress)} / {formatStudyTime(applicationProgress.required)}</span>
<span>Mana/hr: {fmt(applicationProgress.manaPerHour)}</span>
</div>
{design && (
<div className="text-xs text-amber-400/70 mt-1">
Applying: {design.name}
</div>
)}
</div>
);
}
return progressSections.length > 0 ? (
<div className="space-y-2">
{progressSections}
</div>
) : null;
}

View File

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

View File

@@ -0,0 +1,123 @@
'use client';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent } from '@/components/ui/card';
import { Zap, ChevronDown, ChevronUp } from 'lucide-react';
import { fmt, fmtDec } from '@/lib/game/store';
import { ELEMENTS } from '@/lib/game/constants';
import { useState } from 'react';
interface ManaDisplayProps {
rawMana: number;
maxMana: number;
effectiveRegen: number;
meditationMultiplier: number;
clickMana: number;
isGathering: boolean;
onGatherStart: () => void;
onGatherEnd: () => void;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
}
export function ManaDisplay({
rawMana,
maxMana,
effectiveRegen,
meditationMultiplier,
clickMana,
isGathering,
onGatherStart,
onGatherEnd,
elements,
}: ManaDisplayProps) {
const [expanded, setExpanded] = useState(true);
// Get unlocked elements sorted by current amount
const unlockedElements = Object.entries(elements)
.filter(([, state]) => state.unlocked)
.sort((a, b) => b[1].current - a[1].current);
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardContent className="pt-4 space-y-3">
{/* Raw Mana - Main Display */}
<div>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold game-mono text-blue-400">{fmt(rawMana)}</span>
<span className="text-sm text-gray-400">/ {fmt(maxMana)}</span>
</div>
<div className="text-xs text-gray-400">
+{fmtDec(effectiveRegen)} mana/hr {meditationMultiplier > 1.01 && <span className="text-purple-400">({fmtDec(meditationMultiplier, 1)}x med)</span>}
</div>
</div>
<Progress
value={(rawMana / maxMana) * 100}
className="h-2 bg-gray-800"
/>
<Button
className={`w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 ${isGathering ? 'animate-pulse' : ''}`}
onMouseDown={onGatherStart}
onMouseUp={onGatherEnd}
onMouseLeave={onGatherEnd}
onTouchStart={onGatherStart}
onTouchEnd={onGatherEnd}
>
<Zap className="w-4 h-4 mr-2" />
Gather +{clickMana} Mana
{isGathering && <span className="ml-2 text-xs">(Holding...)</span>}
</Button>
{/* Elemental Mana Pools */}
{unlockedElements.length > 0 && (
<div className="border-t border-gray-700 pt-3 mt-3">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-between w-full text-xs text-gray-400 hover:text-gray-300 mb-2"
>
<span>Elemental Mana ({unlockedElements.length})</span>
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
{expanded && (
<div className="grid grid-cols-2 gap-2">
{unlockedElements.map(([id, state]) => {
const elem = ELEMENTS[id];
if (!elem) return null;
return (
<div
key={id}
className="p-2 rounded bg-gray-800/50 border border-gray-700"
>
<div className="flex items-center gap-1 mb-1">
<span style={{ color: elem.color }}>{elem.sym}</span>
<span className="text-xs font-medium" style={{ color: elem.color }}>
{elem.name}
</span>
</div>
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden mb-1">
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.min(100, (state.current / state.max) * 100)}%`,
backgroundColor: elem.color
}}
/>
</div>
<div className="text-xs text-gray-400 game-mono">
{fmt(state.current)}/{fmt(state.max)}
</div>
</div>
);
})}
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { BookOpen, X } from 'lucide-react';
import { SKILLS_DEF, SPELLS_DEF } from '@/lib/game/constants';
import { formatStudyTime } from '@/lib/game/formatting';
import type { StudyTarget } from '@/lib/game/types';
interface StudyProgressProps {
currentStudyTarget: StudyTarget | null;
skills: Record<string, number>;
studySpeedMult: number;
cancelStudy: () => void;
}
export function StudyProgress({
currentStudyTarget,
skills,
studySpeedMult,
cancelStudy,
}: StudyProgressProps) {
if (!currentStudyTarget) return null;
const target = currentStudyTarget;
const progressPct = Math.min(100, (target.progress / target.required) * 100);
const isSkill = target.type === 'skill';
const def = isSkill ? SKILLS_DEF[target.id] : SPELLS_DEF[target.id];
const currentLevel = isSkill ? (skills[target.id] || 0) : 0;
return (
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-purple-400" />
<span className="text-sm font-semibold text-purple-300">
{def?.name}
{isSkill && ` Lv.${currentLevel + 1}`}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={cancelStudy}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={progressPct} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
<span>{studySpeedMult.toFixed(1)}x speed</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { Play, Pause } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { fmt } from '@/lib/game/store';
import { formatHour } from '@/lib/game/formatting';
interface TimeDisplayProps {
day: number;
hour: number;
insight: number;
paused: boolean;
onTogglePause: () => void;
}
export function TimeDisplay({
day,
hour,
insight,
paused,
onTogglePause,
}: TimeDisplayProps) {
return (
<div className="flex items-center gap-4">
<div className="text-center">
<div className="text-lg font-bold game-mono text-amber-400">
Day {day}
</div>
<div className="text-xs text-gray-400">
{formatHour(hour)}
</div>
</div>
<div className="text-center">
<div className="text-lg font-bold game-mono text-purple-400">
{fmt(insight)}
</div>
<div className="text-xs text-gray-400">Insight</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={onTogglePause}
className="text-gray-400 hover:text-white"
>
{paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
</Button>
</div>
);
}

View File

@@ -0,0 +1,115 @@
'use client';
import { SKILLS_DEF } from '@/lib/game/constants';
import type { SkillUpgradeChoice } from '@/lib/game/types';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
export interface UpgradeDialogProps {
open: boolean;
skillId: string | null;
milestone: 5 | 10;
pendingSelections: string[];
available: SkillUpgradeChoice[];
alreadySelected: string[];
onToggle: (upgradeId: string) => void;
onConfirm: () => void;
onCancel: () => void;
onOpenChange: (open: boolean) => void;
}
export function UpgradeDialog({
open,
skillId,
milestone,
pendingSelections,
available,
alreadySelected,
onToggle,
onConfirm,
onCancel,
onOpenChange,
}: UpgradeDialogProps) {
if (!skillId) return null;
const skillDef = SKILLS_DEF[skillId];
const currentSelections = pendingSelections.length > 0 ? pendingSelections : alreadySelected;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
<DialogHeader>
<DialogTitle className="text-amber-400">
Choose Upgrade - {skillDef?.name || skillId}
</DialogTitle>
<DialogDescription className="text-gray-400">
Level {milestone} Milestone - Select 2 upgrades ({currentSelections.length}/2 chosen)
</DialogDescription>
</DialogHeader>
<div className="space-y-2 mt-4">
{available.map((upgrade) => {
const isSelected = currentSelections.includes(upgrade.id);
const canToggle = currentSelections.length < 2 || isSelected;
return (
<div
key={upgrade.id}
className={`p-3 rounded border cursor-pointer transition-all ${
isSelected
? 'border-amber-500 bg-amber-900/30'
: canToggle
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
: 'border-gray-700 bg-gray-800/30 opacity-50 cursor-not-allowed'
}`}
onClick={() => {
if (canToggle) {
onToggle(upgrade.id);
}
}}
>
<div className="flex items-center justify-between">
<div className="font-semibold text-sm text-amber-300">{upgrade.name}</div>
{isSelected && <Badge className="bg-amber-600 text-amber-100">Selected</Badge>}
</div>
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
{upgrade.effect.type === 'multiplier' && (
<div className="text-xs text-green-400 mt-1">
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'bonus' && (
<div className="text-xs text-blue-400 mt-1">
+{upgrade.effect.value} {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'special' && (
<div className="text-xs text-cyan-400 mt-1">
{upgrade.effect.specialDesc || 'Special effect'}
</div>
)}
</div>
);
})}
</div>
<div className="flex justify-end gap-2 mt-4">
<Button
variant="outline"
onClick={onCancel}
>
Cancel
</Button>
<Button
variant="default"
onClick={onConfirm}
disabled={currentSelections.length !== 2}
>
{currentSelections.length < 2 ? `Select ${2 - currentSelections.length} more` : 'Confirm'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

20
src/components/game/index.ts Executable file
View File

@@ -0,0 +1,20 @@
// ─── Game Components Index ──────────────────────────────────────────────────────
// Re-exports all game tab components for cleaner imports
// Tab components
export { CraftingTab } from './tabs/CraftingTab';
export { SpireTab } from './tabs/SpireTab';
export { SpellsTab } from './tabs/SpellsTab';
export { LabTab } from './tabs/LabTab';
export { SkillsTab } from './tabs/SkillsTab';
export { StatsTab } from './tabs/StatsTab';
// UI components
export { ActionButtons } from './ActionButtons';
export { CalendarDisplay } from './CalendarDisplay';
export { ComboMeter } from './ComboMeter';
export { CraftingProgress } from './CraftingProgress';
export { StudyProgress } from './StudyProgress';
export { ManaDisplay } from './ManaDisplay';
export { TimeDisplay } from './TimeDisplay';
export { UpgradeDialog } from './UpgradeDialog';

View File

@@ -0,0 +1,268 @@
'use client';
import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getTotalAttunementRegen, getAvailableSkillCategories, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL, getAttunementConversionRate } from '@/lib/game/data/attunements';
import { ELEMENTS } from '@/lib/game/constants';
import type { GameStore, AttunementState } from '@/lib/game/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Lock, Sparkles, TrendingUp } from 'lucide-react';
export interface AttunementsTabProps {
store: GameStore;
}
export function AttunementsTab({ store }: AttunementsTabProps) {
const attunements = store.attunements || {};
// Get active attunements
const activeAttunements = Object.entries(attunements)
.filter(([, state]) => state.active)
.map(([id]) => ATTUNEMENTS_DEF[id])
.filter(Boolean);
// Calculate total regen from attunements
const totalAttunementRegen = getTotalAttunementRegen(attunements);
// Get available skill categories
const availableCategories = getAvailableSkillCategories(attunements);
return (
<div className="space-y-4">
{/* Overview Card */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Your Attunements</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-400 mb-3">
Attunements are magical bonds tied to specific body locations. Each attunement grants unique capabilities,
mana regeneration, and access to specialized skills. Level them up to increase their power.
</p>
<div className="flex flex-wrap gap-2">
<Badge className="bg-teal-900/50 text-teal-300">
+{totalAttunementRegen.toFixed(1)} raw mana/hr
</Badge>
<Badge className="bg-purple-900/50 text-purple-300">
{activeAttunements.length} active attunement{activeAttunements.length !== 1 ? 's' : ''}
</Badge>
</div>
</CardContent>
</Card>
{/* Attunement Slots */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(ATTUNEMENTS_DEF).map(([id, def]) => {
const state = attunements[id];
const isActive = state?.active;
const isUnlocked = state?.active || def.unlocked;
const level = state?.level || 1;
const xp = state?.experience || 0;
const xpNeeded = getAttunementXPForLevel(level + 1);
const xpProgress = xpNeeded > 0 ? (xp / xpNeeded) * 100 : 100;
const isMaxLevel = level >= MAX_ATTUNEMENT_LEVEL;
// Get primary mana element info
const primaryElem = def.primaryManaType ? ELEMENTS[def.primaryManaType] : null;
// Get current mana for this attunement's type
const currentMana = def.primaryManaType ? store.elements[def.primaryManaType]?.current || 0 : 0;
const maxMana = def.primaryManaType ? store.elements[def.primaryManaType]?.max || 50 : 50;
// Calculate level-scaled stats
const levelMult = Math.pow(1.5, level - 1);
const scaledRegen = def.rawManaRegen * levelMult;
const scaledConversion = getAttunementConversionRate(id, level);
return (
<Card
key={id}
className={`bg-gray-900/80 transition-all ${
isActive
? 'border-2 shadow-lg'
: isUnlocked
? 'border-gray-600'
: 'border-gray-800 opacity-70'
}`}
style={{
borderColor: isActive ? def.color : undefined,
boxShadow: isActive ? `0 0 20px ${def.color}30` : undefined
}}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-2xl">{def.icon}</span>
<div>
<CardTitle className="text-sm" style={{ color: isActive ? def.color : '#9CA3AF' }}>
{def.name}
</CardTitle>
<div className="text-xs text-gray-500">
{ATTUNEMENT_SLOT_NAMES[def.slot]}
</div>
</div>
</div>
{!isUnlocked && (
<Lock className="w-4 h-4 text-gray-600" />
)}
{isActive && (
<Badge className="text-xs" style={{ backgroundColor: `${def.color}30`, color: def.color }}>
Lv.{level}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-gray-400">{def.desc}</p>
{/* Mana Type */}
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500">Primary Mana</span>
{primaryElem ? (
<span style={{ color: primaryElem.color }}>
{primaryElem.sym} {primaryElem.name}
</span>
) : (
<span className="text-purple-400">From Pacts</span>
)}
</div>
{/* Mana bar (only for attunements with primary type) */}
{primaryElem && isActive && (
<div className="space-y-1">
<Progress
value={(currentMana / maxMana) * 100}
className="h-2 bg-gray-800"
/>
<div className="flex justify-between text-xs text-gray-500">
<span>{currentMana.toFixed(1)}</span>
<span>/{maxMana}</span>
</div>
</div>
)}
</div>
{/* Stats with level scaling */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="p-2 bg-gray-800/50 rounded">
<div className="text-gray-500">Raw Regen</div>
<div className="text-green-400 font-semibold">
+{scaledRegen.toFixed(2)}/hr
{level > 1 && <span className="text-xs ml-1">({((levelMult - 1) * 100).toFixed(0)}% bonus)</span>}
</div>
</div>
<div className="p-2 bg-gray-800/50 rounded">
<div className="text-gray-500">Conversion</div>
<div className="text-cyan-400 font-semibold">
{scaledConversion > 0 ? `${scaledConversion.toFixed(2)}/hr` : '—'}
{level > 1 && scaledConversion > 0 && <span className="text-xs ml-1">({((levelMult - 1) * 100).toFixed(0)}% bonus)</span>}
</div>
</div>
</div>
{/* XP Progress Bar */}
{isUnlocked && state && !isMaxLevel && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500 flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
XP Progress
</span>
<span className="text-amber-400">{xp} / {xpNeeded}</span>
</div>
<Progress
value={xpProgress}
className="h-2 bg-gray-800"
/>
<div className="text-xs text-gray-500">
{isMaxLevel ? 'Max Level' : `${xpNeeded - xp} XP to Level ${level + 1}`}
</div>
</div>
)}
{/* Max Level Indicator */}
{isMaxLevel && (
<div className="text-xs text-amber-400 text-center font-semibold">
MAX LEVEL
</div>
)}
{/* Capabilities */}
<div className="space-y-1">
<div className="text-xs text-gray-500">Capabilities</div>
<div className="flex flex-wrap gap-1">
{def.capabilities.map(cap => (
<Badge key={cap} variant="outline" className="text-xs">
{cap === 'enchanting' && '✨ Enchanting'}
{cap === 'disenchanting' && '🔄 Disenchant'}
{cap === 'pacts' && '🤝 Pacts'}
{cap === 'guardianPowers' && '💜 Guardian Powers'}
{cap === 'elementalMastery' && '🌟 Elem. Mastery'}
{cap === 'golemCrafting' && '🗿 Golems'}
{cap === 'gearCrafting' && '⚒️ Gear'}
{cap === 'earthShaping' && '⛰️ Earth Shaping'}
{!['enchanting', 'disenchanting', 'pacts', 'guardianPowers',
'elementalMastery', 'golemCrafting', 'gearCrafting', 'earthShaping'].includes(cap) && cap}
</Badge>
))}
</div>
</div>
{/* Unlock condition for locked attunements */}
{!isUnlocked && def.unlockCondition && (
<div className="text-xs text-amber-400 italic">
🔒 {def.unlockCondition}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
{/* Available Skills Summary */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Available Skill Categories</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-gray-400 mb-2">
Your attunements grant access to specialized skill categories:
</p>
<div className="flex flex-wrap gap-2">
{availableCategories.map(cat => {
const attunement = Object.values(ATTUNEMENTS_DEF).find(a =>
a.skillCategories.includes(cat) && attunements[a.id]?.active
);
return (
<Badge
key={cat}
className={attunement ? '' : 'bg-gray-700/50 text-gray-400'}
style={attunement ? {
backgroundColor: `${attunement.color}30`,
color: attunement.color
} : undefined}
>
{cat === 'mana' && '💧 Mana'}
{cat === 'study' && '📚 Study'}
{cat === 'research' && '🔮 Research'}
{cat === 'ascension' && '⭐ Ascension'}
{cat === 'enchant' && '✨ Enchanting'}
{cat === 'effectResearch' && '🔬 Effect Research'}
{cat === 'invocation' && '💜 Invocation'}
{cat === 'pact' && '🤝 Pact Mastery'}
{cat === 'fabrication' && '⚒️ Fabrication'}
{cat === 'golemancy' && '🗿 Golemancy'}
{!['mana', 'study', 'research', 'ascension', 'enchant', 'effectResearch',
'invocation', 'pact', 'fabrication', 'golemancy'].includes(cat) && cat}
</Badge>
);
})}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,975 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Wand2, Scroll, Hammer, Sparkles, Trash2, Plus, Minus,
Package, Zap, Clock, ChevronRight, Circle, Anvil
} from 'lucide-react';
import { EQUIPMENT_TYPES, type EquipmentType, type EquipmentSlot } from '@/lib/game/data/equipment';
import { ENCHANTMENT_EFFECTS, type EnchantmentEffectDef, calculateEffectCapacityCost } from '@/lib/game/data/enchantment-effects';
import { CRAFTING_RECIPES, canCraftRecipe } from '@/lib/game/data/crafting-recipes';
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, AppliedEnchantment, LootInventory, EquipmentCraftingProgress } from '@/lib/game/types';
import { fmt, type GameStore } from '@/lib/game/store';
// Slot display names
const SLOT_NAMES: Record<EquipmentSlot, string> = {
mainHand: 'Main Hand',
offHand: 'Off Hand',
head: 'Head',
body: 'Body',
hands: 'Hands',
feet: 'Feet',
accessory1: 'Accessory 1',
accessory2: 'Accessory 2',
};
export interface CraftingTabProps {
store: GameStore;
}
export function CraftingTab({ store }: CraftingTabProps) {
const equippedInstances = store.equippedInstances;
const equipmentInstances = store.equipmentInstances;
const enchantmentDesigns = store.enchantmentDesigns;
const designProgress = store.designProgress;
const preparationProgress = store.preparationProgress;
const applicationProgress = store.applicationProgress;
const equipmentCraftingProgress = store.equipmentCraftingProgress;
const rawMana = store.rawMana;
const skills = store.skills;
const currentAction = store.currentAction;
const unlockedEffects = store.unlockedEffects;
const lootInventory = store.lootInventory;
const startDesigningEnchantment = store.startDesigningEnchantment;
const cancelDesign = store.cancelDesign;
const saveDesign = store.saveDesign;
const deleteDesign = store.deleteDesign;
const startPreparing = store.startPreparing;
const cancelPreparation = store.cancelPreparation;
const startApplying = store.startApplying;
const pauseApplication = store.pauseApplication;
const resumeApplication = store.resumeApplication;
const cancelApplication = store.cancelApplication;
const disenchantEquipment = store.disenchantEquipment;
const getAvailableCapacity = store.getAvailableCapacity;
const startCraftingEquipment = store.startCraftingEquipment;
const cancelEquipmentCrafting = store.cancelEquipmentCrafting;
const deleteMaterial = store.deleteMaterial;
const [craftingStage, setCraftingStage] = useState<'design' | 'prepare' | 'apply' | 'craft'>('craft');
const [selectedEquipmentType, setSelectedEquipmentType] = useState<string | null>(null);
const [selectedEquipmentInstance, setSelectedEquipmentInstance] = useState<string | null>(null);
const [selectedDesign, setSelectedDesign] = useState<string | null>(null);
// Design creation state
const [designName, setDesignName] = useState('');
const [selectedEffects, setSelectedEffects] = useState<DesignEffect[]>([]);
const enchantingLevel = skills.enchanting || 0;
const efficiencyBonus = (skills.efficientEnchant || 0) * 0.05;
// Get equipped items as array
const equippedItems = Object.entries(equippedInstances)
.filter(([, instanceId]) => instanceId && equipmentInstances[instanceId])
.map(([slot, instanceId]) => ({
slot: slot as EquipmentSlot,
instance: equipmentInstances[instanceId!],
}));
// Calculate total capacity cost for current design
const designCapacityCost = selectedEffects.reduce(
(total, eff) => total + calculateEffectCapacityCost(eff.effectId, eff.stacks, efficiencyBonus),
0
);
// Calculate design time
const designTime = selectedEffects.reduce((total, eff) => total + 0.5 * eff.stacks, 1);
// Add effect to design
const addEffect = (effectId: string) => {
const existing = selectedEffects.find(e => e.effectId === effectId);
const effectDef = ENCHANTMENT_EFFECTS[effectId];
if (!effectDef) return;
if (existing) {
if (existing.stacks < effectDef.maxStacks) {
setSelectedEffects(selectedEffects.map(e =>
e.effectId === effectId
? { ...e, stacks: e.stacks + 1 }
: e
));
}
} else {
setSelectedEffects([...selectedEffects, {
effectId,
stacks: 1,
capacityCost: calculateEffectCapacityCost(effectId, 1, efficiencyBonus),
}]);
}
};
// Remove effect from design
const removeEffect = (effectId: string) => {
const existing = selectedEffects.find(e => e.effectId === effectId);
if (!existing) return;
if (existing.stacks > 1) {
setSelectedEffects(selectedEffects.map(e =>
e.effectId === effectId
? { ...e, stacks: e.stacks - 1 }
: e
));
} else {
setSelectedEffects(selectedEffects.filter(e => e.effectId !== effectId));
}
};
// Create design
const handleCreateDesign = () => {
if (!designName || !selectedEquipmentType || selectedEffects.length === 0) return;
const success = startDesigningEnchantment(designName, selectedEquipmentType, selectedEffects);
if (success) {
// Reset form
setDesignName('');
setSelectedEquipmentType(null);
setSelectedEffects([]);
}
};
// Complete design after progress
const handleCompleteDesign = () => {
if (!designProgress || !selectedEquipmentType) return;
const design: EnchantmentDesign = {
id: designProgress.designId,
name: designName || 'Untitled Design',
equipmentType: selectedEquipmentType,
effects: selectedEffects,
totalCapacityUsed: designCapacityCost,
designTime,
created: Date.now(),
};
saveDesign(design);
setDesignName('');
setSelectedEquipmentType(null);
setSelectedEffects([]);
};
// Get available effects for selected equipment type (only unlocked ones)
const getAvailableEffects = () => {
if (!selectedEquipmentType) return [];
const type = EQUIPMENT_TYPES[selectedEquipmentType];
if (!type) return [];
return Object.values(ENCHANTMENT_EFFECTS).filter(
effect =>
effect.allowedEquipmentCategories.includes(type.category) &&
unlockedEffects.includes(effect.id)
);
};
// Render design stage
const renderDesignStage = () => (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Equipment Type Selection */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">1. Select Equipment Type</CardTitle>
</CardHeader>
<CardContent>
{designProgress ? (
<div className="space-y-3">
<div className="text-sm text-gray-400">Designing for: {EQUIPMENT_TYPES[selectedEquipmentType || '']?.name}</div>
<Progress value={(designProgress.progress / designProgress.required) * 100} className="h-3" />
<div className="flex justify-between text-xs text-gray-400">
<span>{designProgress.progress.toFixed(1)}h / {designProgress.required.toFixed(1)}h</span>
<Button size="sm" variant="outline" onClick={cancelDesign}>Cancel</Button>
</div>
{designProgress.progress >= designProgress.required && (
<Button onClick={handleCompleteDesign} className="w-full">Complete Design</Button>
)}
</div>
) : (
<ScrollArea className="h-64">
<div className="grid grid-cols-2 gap-2">
{Object.values(EQUIPMENT_TYPES).map(type => (
<div
key={type.id}
className={`p-2 rounded border cursor-pointer transition-all ${
selectedEquipmentType === type.id
? 'border-amber-500 bg-amber-900/20'
: 'border-gray-700 bg-gray-800/50 hover:border-gray-600'
}`}
onClick={() => setSelectedEquipmentType(type.id)}
>
<div className="text-sm font-semibold">{type.name}</div>
<div className="text-xs text-gray-400">Cap: {type.baseCapacity}</div>
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
{/* Effect Selection */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">2. Select Effects</CardTitle>
</CardHeader>
<CardContent>
{enchantingLevel < 1 ? (
<div className="text-center text-gray-400 py-8">
<Wand2 className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Learn Enchanting skill to design enchantments</p>
</div>
) : designProgress ? (
<div className="space-y-2">
<div className="text-sm text-gray-400">Design in progress...</div>
{selectedEffects.map(eff => {
const def = ENCHANTMENT_EFFECTS[eff.effectId];
return (
<div key={eff.effectId} className="flex justify-between text-sm">
<span>{def?.name} x{eff.stacks}</span>
<span className="text-gray-400">{eff.capacityCost} cap</span>
</div>
);
})}
</div>
) : !selectedEquipmentType ? (
<div className="text-center text-gray-400 py-8">
Select an equipment type first
</div>
) : (
<>
<ScrollArea className="h-48 mb-4">
<div className="space-y-2">
{getAvailableEffects().map(effect => {
const selected = selectedEffects.find(e => e.effectId === effect.id);
const cost = calculateEffectCapacityCost(effect.id, (selected?.stacks || 0) + 1, efficiencyBonus);
return (
<div
key={effect.id}
className={`p-2 rounded border transition-all ${
selected
? 'border-purple-500 bg-purple-900/20'
: 'border-gray-700 bg-gray-800/50'
}`}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="text-sm font-semibold">{effect.name}</div>
<div className="text-xs text-gray-400">{effect.description}</div>
<div className="text-xs text-gray-500 mt-1">
Cost: {effect.baseCapacityCost} cap | Max: {effect.maxStacks}
</div>
</div>
<div className="flex gap-1">
{selected && (
<Button
size="sm"
variant="outline"
className="h-6 w-6 p-0"
onClick={() => removeEffect(effect.id)}
>
<Minus className="w-3 h-3" />
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-6 w-6 p-0"
onClick={() => addEffect(effect.id)}
disabled={!selected && selectedEffects.length >= 5}
>
<Plus className="w-3 h-3" />
</Button>
</div>
</div>
{selected && (
<Badge variant="outline" className="mt-1 text-xs">
{selected.stacks}/{effect.maxStacks}
</Badge>
)}
</div>
);
})}
</div>
</ScrollArea>
{/* Selected effects summary */}
<Separator className="bg-gray-700 my-2" />
<div className="space-y-2">
<input
type="text"
placeholder="Design name..."
value={designName}
onChange={(e) => setDesignName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm"
/>
<div className="flex justify-between text-sm">
<span>Total Capacity:</span>
<span className={designCapacityCost > 100 ? 'text-red-400' : 'text-green-400'}>
{designCapacityCost.toFixed(0)}
</span>
</div>
<div className="flex justify-between text-sm text-gray-400">
<span>Design Time:</span>
<span>{designTime.toFixed(1)}h</span>
</div>
<Button
className="w-full"
disabled={!designName || selectedEffects.length === 0}
onClick={handleCreateDesign}
>
Start Design ({designTime.toFixed(1)}h)
</Button>
</div>
</>
)}
</CardContent>
</Card>
{/* Saved Designs */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Saved Designs ({enchantmentDesigns.length})</CardTitle>
</CardHeader>
<CardContent>
{enchantmentDesigns.length === 0 ? (
<div className="text-center text-gray-400 py-4">
No saved designs yet
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{enchantmentDesigns.map(design => (
<div
key={design.id}
className={`p-3 rounded border ${
selectedDesign === design.id
? 'border-amber-500 bg-amber-900/20'
: 'border-gray-700 bg-gray-800/50'
}`}
onClick={() => setSelectedDesign(design.id)}
>
<div className="flex justify-between items-start">
<div>
<div className="font-semibold">{design.name}</div>
<div className="text-xs text-gray-400">
{EQUIPMENT_TYPES[design.equipmentType]?.name}
</div>
</div>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-gray-400 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
deleteDesign(design.id);
}}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
<div className="mt-2 text-xs text-gray-400">
{design.effects.length} effects | {design.totalCapacityUsed} cap
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
// Render prepare stage
const renderPrepareStage = () => (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Equipment Selection */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Select Equipment to Prepare</CardTitle>
</CardHeader>
<CardContent>
{preparationProgress ? (
<div className="space-y-3">
<div className="text-sm text-gray-400">
Preparing: {equipmentInstances[preparationProgress.equipmentInstanceId]?.name}
</div>
<Progress value={(preparationProgress.progress / preparationProgress.required) * 100} className="h-3" />
<div className="flex justify-between text-xs text-gray-400">
<span>{preparationProgress.progress.toFixed(1)}h / {preparationProgress.required.toFixed(1)}h</span>
<span>Mana paid: {fmt(preparationProgress.manaCostPaid)}</span>
</div>
<Button size="sm" variant="outline" onClick={cancelPreparation}>Cancel</Button>
</div>
) : (
<ScrollArea className="h-64">
<div className="space-y-2">
{equippedItems.map(({ slot, instance }) => (
<div
key={instance.instanceId}
className={`p-3 rounded border cursor-pointer transition-all ${
selectedEquipmentInstance === instance.instanceId
? 'border-amber-500 bg-amber-900/20'
: 'border-gray-700 bg-gray-800/50 hover:border-gray-600'
}`}
onClick={() => setSelectedEquipmentInstance(instance.instanceId)}
>
<div className="flex justify-between">
<div>
<div className="font-semibold">{instance.name}</div>
<div className="text-xs text-gray-400">{SLOT_NAMES[slot]}</div>
</div>
<div className="text-right text-sm">
<div className="text-green-400">{instance.usedCapacity}/{instance.totalCapacity} cap</div>
<div className="text-xs text-gray-400">{instance.enchantments.length} enchants</div>
</div>
</div>
</div>
))}
{equippedItems.length === 0 && (
<div className="text-center text-gray-400 py-4">No equipped items</div>
)}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
{/* Preparation Details */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Preparation Details</CardTitle>
</CardHeader>
<CardContent>
{!selectedEquipmentInstance ? (
<div className="text-center text-gray-400 py-8">
Select equipment to prepare
</div>
) : preparationProgress ? (
<div className="text-gray-400">Preparation in progress...</div>
) : (
(() => {
const instance = equipmentInstances[selectedEquipmentInstance];
const prepTime = 2 + Math.floor(instance.totalCapacity / 50);
const manaCost = instance.totalCapacity * 10;
return (
<div className="space-y-4">
<div className="text-lg font-semibold">{instance.name}</div>
<Separator className="bg-gray-700" />
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Capacity:</span>
<span>{instance.usedCapacity}/{instance.totalCapacity}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Prep Time:</span>
<span>{prepTime}h</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Mana Cost:</span>
<span className={rawMana < manaCost ? 'text-red-400' : 'text-green-400'}>
{fmt(manaCost)}
</span>
</div>
</div>
<Button
className="w-full"
disabled={rawMana < manaCost}
onClick={() => startPreparing(selectedEquipmentInstance)}
>
Start Preparation ({prepTime}h, {fmt(manaCost)} mana)
</Button>
</div>
);
})()
)}
</CardContent>
</Card>
</div>
);
// Render apply stage
const renderApplyStage = () => (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Equipment & Design Selection */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Select Equipment & Design</CardTitle>
</CardHeader>
<CardContent>
{applicationProgress ? (
<div className="space-y-3">
<div className="text-sm text-gray-400">
Applying to: {equipmentInstances[applicationProgress.equipmentInstanceId]?.name}
</div>
<Progress value={(applicationProgress.progress / applicationProgress.required) * 100} className="h-3" />
<div className="flex justify-between text-xs text-gray-400">
<span>{applicationProgress.progress.toFixed(1)}h / {applicationProgress.required.toFixed(1)}h</span>
<span>Mana spent: {fmt(applicationProgress.manaSpent)}</span>
</div>
<div className="flex gap-2">
{applicationProgress.paused ? (
<Button size="sm" onClick={resumeApplication}>Resume</Button>
) : (
<Button size="sm" variant="outline" onClick={pauseApplication}>Pause</Button>
)}
<Button size="sm" variant="outline" onClick={cancelApplication}>Cancel</Button>
</div>
</div>
) : (
<div className="space-y-4">
<div>
<div className="text-sm text-gray-400 mb-2">Equipment:</div>
<ScrollArea className="h-32">
<div className="space-y-1">
{equippedItems.map(({ slot, instance }) => (
<div
key={instance.instanceId}
className={`p-2 rounded border cursor-pointer text-sm ${
selectedEquipmentInstance === instance.instanceId
? 'border-amber-500 bg-amber-900/20'
: 'border-gray-700 bg-gray-800/50'
}`}
onClick={() => setSelectedEquipmentInstance(instance.instanceId)}
>
{instance.name} ({instance.usedCapacity}/{instance.totalCapacity} cap)
</div>
))}
</div>
</ScrollArea>
</div>
<div>
<div className="text-sm text-gray-400 mb-2">Design:</div>
<ScrollArea className="h-32">
<div className="space-y-1">
{enchantmentDesigns.map(design => (
<div
key={design.id}
className={`p-2 rounded border cursor-pointer text-sm ${
selectedDesign === design.id
? 'border-purple-500 bg-purple-900/20'
: 'border-gray-700 bg-gray-800/50'
}`}
onClick={() => setSelectedDesign(design.id)}
>
{design.name} ({design.totalCapacityUsed} cap)
</div>
))}
</div>
</ScrollArea>
</div>
</div>
)}
</CardContent>
</Card>
{/* Application Details */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Apply Enchantment</CardTitle>
</CardHeader>
<CardContent>
{!selectedEquipmentInstance || !selectedDesign ? (
<div className="text-center text-gray-400 py-8">
Select equipment and a design
</div>
) : applicationProgress ? (
<div className="text-gray-400">Application in progress...</div>
) : (
(() => {
const instance = equipmentInstances[selectedEquipmentInstance];
const design = enchantmentDesigns.find(d => d.id === selectedDesign);
if (!design) return null;
const availableCap = instance.totalCapacity - instance.usedCapacity;
const canFit = availableCap >= design.totalCapacityUsed;
const applicationTime = 2 + design.effects.reduce((t, e) => t + e.stacks, 0);
const manaPerHour = 20 + design.effects.reduce((t, e) => t + e.stacks * 5, 0);
return (
<div className="space-y-4">
<div className="text-lg font-semibold">{design.name}</div>
<div className="text-sm text-gray-400"> {instance.name}</div>
<Separator className="bg-gray-700" />
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Required Capacity:</span>
<span className={canFit ? 'text-green-400' : 'text-red-400'}>
{design.totalCapacityUsed} / {availableCap} available
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Application Time:</span>
<span>{applicationTime}h</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Mana per Hour:</span>
<span>{manaPerHour}</span>
</div>
</div>
<div className="text-sm text-gray-400">
Effects:
<ul className="list-disc list-inside">
{design.effects.map(eff => (
<li key={eff.effectId}>
{ENCHANTMENT_EFFECTS[eff.effectId]?.name} x{eff.stacks}
</li>
))}
</ul>
</div>
<Button
className="w-full"
disabled={!canFit}
onClick={() => startApplying(selectedEquipmentInstance, selectedDesign)}
>
Apply Enchantment
</Button>
</div>
);
})()
)}
</CardContent>
</Card>
{/* Disenchant Section */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Disenchant Equipment</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{equippedItems
.filter(({ instance }) => instance.enchantments.length > 0)
.map(({ slot, instance }) => {
const disenchantLevel = skills.disenchanting || 0;
const recoveryRate = 0.1 + disenchantLevel * 0.2;
const totalRecoverable = instance.enchantments.reduce(
(sum, e) => sum + Math.floor(e.actualCost * recoveryRate),
0
);
return (
<div key={instance.instanceId} className="p-3 rounded border border-gray-700 bg-gray-800/50">
<div className="flex justify-between items-start">
<div>
<div className="font-semibold">{instance.name}</div>
<div className="text-xs text-gray-400">{instance.enchantments.length} enchantments</div>
</div>
<Button
size="sm"
variant="outline"
onClick={() => disenchantEquipment(instance.instanceId)}
>
<Trash2 className="w-4 h-4 mr-1" />
Recover {fmt(totalRecoverable)}
</Button>
</div>
</div>
);
})}
{equippedItems.filter(({ instance }) => instance.enchantments.length > 0).length === 0 && (
<div className="col-span-full text-center text-gray-400 py-4">
No enchanted equipment to disenchant
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
// Render equipment crafting stage
const renderCraftStage = () => (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Blueprint Selection */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
<Anvil className="w-4 h-4" />
Available Blueprints
</CardTitle>
</CardHeader>
<CardContent>
{equipmentCraftingProgress ? (
<div className="space-y-3">
<div className="text-sm text-gray-400">
Crafting: {CRAFTING_RECIPES[equipmentCraftingProgress.blueprintId]?.name}
</div>
<Progress value={(equipmentCraftingProgress.progress / equipmentCraftingProgress.required) * 100} className="h-3" />
<div className="flex justify-between text-xs text-gray-400">
<span>{equipmentCraftingProgress.progress.toFixed(1)}h / {equipmentCraftingProgress.required.toFixed(1)}h</span>
<span>Mana spent: {fmt(equipmentCraftingProgress.manaSpent)}</span>
</div>
<Button size="sm" variant="outline" onClick={cancelEquipmentCrafting}>Cancel</Button>
</div>
) : (
<ScrollArea className="h-64">
<div className="space-y-2">
{lootInventory.blueprints.length === 0 ? (
<div className="text-center text-gray-400 py-4">
<Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>No blueprints discovered yet.</p>
<p className="text-xs mt-1">Defeat guardians to find blueprints!</p>
</div>
) : (
lootInventory.blueprints.map(bpId => {
const recipe = CRAFTING_RECIPES[bpId];
if (!recipe) return null;
const { canCraft, missingMaterials, missingMana } = canCraftRecipe(
recipe,
lootInventory.materials,
rawMana
);
const rarityStyle = RARITY_COLORS[recipe.rarity];
return (
<div
key={bpId}
className="p-3 rounded border bg-gray-800/50"
style={{ borderColor: rarityStyle?.color }}
>
<div className="flex justify-between items-start mb-2">
<div>
<div className="font-semibold" style={{ color: rarityStyle?.color }}>
{recipe.name}
</div>
<div className="text-xs text-gray-400 capitalize">{recipe.rarity}</div>
</div>
<Badge variant="outline" className="text-xs">
{EQUIPMENT_TYPES[recipe.equipmentTypeId]?.category}
</Badge>
</div>
<div className="text-xs text-gray-400 mb-2">{recipe.description}</div>
<Separator className="bg-gray-700 my-2" />
<div className="text-xs space-y-1">
<div className="text-gray-500">Materials:</div>
{Object.entries(recipe.materials).map(([matId, amount]) => {
const available = lootInventory.materials[matId] || 0;
const matDrop = LOOT_DROPS[matId];
const hasEnough = available >= amount;
return (
<div key={matId} className="flex justify-between">
<span>{matDrop?.name || matId}</span>
<span className={hasEnough ? 'text-green-400' : 'text-red-400'}>
{available} / {amount}
</span>
</div>
);
})}
<div className="flex justify-between mt-2">
<span>Mana Cost:</span>
<span className={rawMana >= recipe.manaCost ? 'text-green-400' : 'text-red-400'}>
{fmt(recipe.manaCost)}
</span>
</div>
<div className="flex justify-between">
<span>Craft Time:</span>
<span>{recipe.craftTime}h</span>
</div>
</div>
<Button
className="w-full mt-3"
size="sm"
disabled={!canCraft || currentAction === 'craft'}
onClick={() => startCraftingEquipment(bpId)}
>
{canCraft ? 'Craft Equipment' : 'Missing Resources'}
</Button>
</div>
);
})
)}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
{/* Materials Inventory */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
<Package className="w-4 h-4" />
Materials ({Object.values(lootInventory.materials).reduce((a, b) => a + b, 0)})
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-64">
{Object.keys(lootInventory.materials).length === 0 ? (
<div className="text-center text-gray-400 py-4">
<Sparkles className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>No materials collected yet.</p>
<p className="text-xs mt-1">Defeat floors to gather materials!</p>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
{Object.entries(lootInventory.materials).map(([matId, count]) => {
if (count <= 0) return null;
const drop = LOOT_DROPS[matId];
if (!drop) return null;
const rarityStyle = RARITY_COLORS[drop.rarity];
return (
<div
key={matId}
className="p-2 rounded border bg-gray-800/50 group relative"
style={{ borderColor: rarityStyle?.color }}
>
<div className="flex items-start justify-between">
<div>
<div className="text-sm font-semibold" style={{ color: rarityStyle?.color }}>
{drop.name}
</div>
<div className="text-xs text-gray-400">x{count}</div>
</div>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => deleteMaterial(matId, count)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
);
})}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
</div>
);
return (
<div className="space-y-4">
{/* Stage Tabs */}
<Tabs value={craftingStage} onValueChange={(v) => setCraftingStage(v as typeof craftingStage)}>
<TabsList className="bg-gray-800/50">
<TabsTrigger value="craft" className="data-[state=active]:bg-cyan-600">
<Anvil className="w-4 h-4 mr-1" />
Craft
</TabsTrigger>
<TabsTrigger value="design" className="data-[state=active]:bg-amber-600">
<Scroll className="w-4 h-4 mr-1" />
Design
</TabsTrigger>
<TabsTrigger value="prepare" className="data-[state=active]:bg-amber-600">
<Hammer className="w-4 h-4 mr-1" />
Prepare
</TabsTrigger>
<TabsTrigger value="apply" className="data-[state=active]:bg-amber-600">
<Sparkles className="w-4 h-4 mr-1" />
Apply
</TabsTrigger>
</TabsList>
<TabsContent value="craft" className="mt-4">
{renderCraftStage()}
</TabsContent>
<TabsContent value="design" className="mt-4">
{renderDesignStage()}
</TabsContent>
<TabsContent value="prepare" className="mt-4">
{renderPrepareStage()}
</TabsContent>
<TabsContent value="apply" className="mt-4">
{renderApplyStage()}
</TabsContent>
</Tabs>
{/* Current Activity Indicator */}
{currentAction === 'craft' && equipmentCraftingProgress && (
<Card className="bg-cyan-900/30 border-cyan-600">
<CardContent className="py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Anvil className="w-5 h-5 text-cyan-400" />
<span>Crafting equipment...</span>
</div>
<div className="text-sm text-gray-400">
{((equipmentCraftingProgress.progress / equipmentCraftingProgress.required) * 100).toFixed(0)}%
</div>
</CardContent>
</Card>
)}
{currentAction === 'design' && designProgress && (
<Card className="bg-purple-900/30 border-purple-600">
<CardContent className="py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Scroll className="w-5 h-5 text-purple-400" />
<span>Designing enchantment...</span>
</div>
<div className="text-sm text-gray-400">
{((designProgress.progress / designProgress.required) * 100).toFixed(0)}%
</div>
</CardContent>
</Card>
)}
{currentAction === 'prepare' && preparationProgress && (
<Card className="bg-blue-900/30 border-blue-600">
<CardContent className="py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Hammer className="w-5 h-5 text-blue-400" />
<span>Preparing equipment...</span>
</div>
<div className="text-sm text-gray-400">
{((preparationProgress.progress / preparationProgress.required) * 100).toFixed(0)}%
</div>
</CardContent>
</Card>
)}
{currentAction === 'enchant' && applicationProgress && (
<Card className="bg-amber-900/30 border-amber-600">
<CardContent className="py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-amber-400" />
<span>{applicationProgress.paused ? 'Enchantment paused' : 'Applying enchantment...'}</span>
</div>
<div className="flex items-center gap-2">
<div className="text-sm text-gray-400">
{((applicationProgress.progress / applicationProgress.required) * 100).toFixed(0)}%
</div>
{applicationProgress.paused ? (
<Button size="sm" onClick={resumeApplication}>Resume</Button>
) : (
<Button size="sm" variant="outline" onClick={pauseApplication}>Pause</Button>
)}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

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

View File

@@ -0,0 +1,393 @@
'use client';
import { useState } from 'react';
import {
EQUIPMENT_TYPES,
EQUIPMENT_SLOTS,
getEquipmentBySlot,
type EquipmentSlot,
type EquipmentType,
} from '@/lib/game/data/equipment';
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
import { fmt } from '@/lib/game/store';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import type { GameStore, EquipmentInstance } from '@/lib/game/types';
export interface EquipmentTabProps {
store: GameStore;
}
// Slot display names
const SLOT_NAMES: Record<EquipmentSlot, string> = {
mainHand: 'Main Hand',
offHand: 'Off Hand',
head: 'Head',
body: 'Body',
hands: 'Hands',
feet: 'Feet',
accessory1: 'Accessory 1',
accessory2: 'Accessory 2',
};
// Slot icons
const SLOT_ICONS: Record<EquipmentSlot, string> = {
mainHand: '⚔️',
offHand: '🛡️',
head: '🎩',
body: '👕',
hands: '🧤',
feet: '👢',
accessory1: '💍',
accessory2: '📿',
};
// Rarity colors
const RARITY_COLORS: Record<string, string> = {
common: 'border-gray-500 bg-gray-800/30',
uncommon: 'border-green-500 bg-green-900/20',
rare: 'border-blue-500 bg-blue-900/20',
epic: 'border-purple-500 bg-purple-900/20',
legendary: 'border-amber-500 bg-amber-900/20',
mythic: 'border-red-500 bg-red-900/20',
};
const RARITY_TEXT_COLORS: Record<string, string> = {
common: 'text-gray-300',
uncommon: 'text-green-400',
rare: 'text-blue-400',
epic: 'text-purple-400',
legendary: 'text-amber-400',
mythic: 'text-red-400',
};
export function EquipmentTab({ store }: EquipmentTabProps) {
const [selectedSlot, setSelectedSlot] = useState<EquipmentSlot | null>(null);
// Get unequipped items
const equippedIds = new Set(Object.values(store.equippedInstances).filter(Boolean));
const unequippedItems = Object.values(store.equipmentInstances).filter(
(inst) => !equippedIds.has(inst.instanceId)
);
// Equip an item to a slot
const handleEquip = (instanceId: string, slot: EquipmentSlot) => {
store.equipItem(instanceId, slot);
setSelectedSlot(null);
};
// Unequip from a slot
const handleUnequip = (slot: EquipmentSlot) => {
store.unequipItem(slot);
};
// Get items that can be equipped in a slot
const getEquippableItems = (slot: EquipmentSlot): EquipmentInstance[] => {
const equipmentTypes = getEquipmentBySlot(slot);
const typeIds = new Set(equipmentTypes.map((t) => t.id));
return unequippedItems.filter((inst) => typeIds.has(inst.typeId));
};
// Get all items that can go in a slot (including accessories that can go in either accessory slot)
const getItemsForSlot = (slot: EquipmentSlot): EquipmentInstance[] => {
if (slot === 'accessory1' || slot === 'accessory2') {
// Accessories can go in either slot
const accessoryTypes = EQUIPMENT_TYPES;
const accessoryTypeIds = Object.values(accessoryTypes)
.filter((t) => t.category === 'accessory')
.map((t) => t.id);
return unequippedItems.filter((inst) => accessoryTypeIds.includes(inst.typeId));
}
return getEquippableItems(slot);
};
return (
<div className="space-y-4">
{/* Equipment Slots */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
Equipped Gear
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{EQUIPMENT_SLOTS.map((slot) => {
const instanceId = store.equippedInstances[slot];
const instance = instanceId ? store.equipmentInstances[instanceId] : null;
const equipmentType = instance ? EQUIPMENT_TYPES[instance.typeId] : null;
return (
<div
key={slot}
className={`p-3 rounded border ${
instance
? RARITY_COLORS[instance.rarity]
: 'border-gray-700 bg-gray-800/30'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span>{SLOT_ICONS[slot]}</span>
<span className="text-sm font-semibold text-gray-300">
{SLOT_NAMES[slot]}
</span>
</div>
{instance && (
<Button
size="sm"
variant="ghost"
className="h-6 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => handleUnequip(slot)}
>
</Button>
)}
</div>
{instance ? (
<div className="space-y-1">
<div className={`font-semibold text-sm ${RARITY_TEXT_COLORS[instance.rarity]}`}>
{instance.name}
</div>
<div className="text-xs text-gray-400">
Capacity: {instance.usedCapacity}/{instance.totalCapacity}
</div>
{instance.enchantments.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{instance.enchantments.map((ench, i) => {
const effect = ENCHANTMENT_EFFECTS[ench.effectId];
return (
<TooltipProvider key={i}>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-xs cursor-help"
>
{effect?.name || ench.effectId}
{ench.stacks > 1 && ` x${ench.stacks}`}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>{effect?.description || 'Unknown effect'}</p>
<p className="text-gray-400 text-xs">
Category: {effect?.category || 'unknown'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
})}
</div>
)}
</div>
) : (
<div className="text-sm text-gray-500 italic">
Empty
</div>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Inventory */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
Equipment Inventory ({unequippedItems.length} items)
</CardTitle>
</CardHeader>
<CardContent>
{unequippedItems.length === 0 ? (
<div className="text-gray-500 text-sm text-center py-4">
No unequipped items. Craft new gear in the Crafting tab.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 max-h-96 overflow-y-auto">
{unequippedItems.map((instance) => {
const equipmentType = EQUIPMENT_TYPES[instance.typeId];
const validSlots = equipmentType
? (equipmentType.category === 'accessory'
? ['accessory1', 'accessory2'] as EquipmentSlot[]
: [equipmentType.slot])
: [];
return (
<div
key={instance.instanceId}
className={`p-3 rounded border ${RARITY_COLORS[instance.rarity]}`}
>
<div className="flex items-start justify-between mb-2">
<div>
<div className={`font-semibold text-sm ${RARITY_TEXT_COLORS[instance.rarity]}`}>
{instance.name}
</div>
<div className="text-xs text-gray-400">
{equipmentType?.description}
</div>
</div>
<Badge variant="outline" className="text-xs">
{equipmentType?.category || 'unknown'}
</Badge>
</div>
<div className="text-xs text-gray-400 space-y-1 mb-2">
<div>
Capacity: {instance.usedCapacity}/{instance.totalCapacity}
{instance.quality < 100 && (
<span className="text-yellow-500 ml-1">
(Quality: {instance.quality}%)
</span>
)}
</div>
{instance.enchantments.length > 0 && (
<div className="flex flex-wrap gap-1">
{instance.enchantments.map((ench, i) => {
const effect = ENCHANTMENT_EFFECTS[ench.effectId];
return (
<Badge
key={i}
variant="outline"
className="text-xs"
>
{effect?.name || ench.effectId}
</Badge>
);
})}
</div>
)}
</div>
{validSlots.length > 0 && (
<div className="flex items-center gap-2">
<Select
onValueChange={(value) =>
handleEquip(instance.instanceId, value as EquipmentSlot)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Equip to..." />
</SelectTrigger>
<SelectContent>
{validSlots.map((slot) => (
<SelectItem
key={slot}
value={slot}
className="text-xs"
>
{SLOT_ICONS[slot]} {SLOT_NAMES[slot]}
</SelectItem>
))}
</SelectContent>
</Select>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-8 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => store.deleteEquipmentInstance(instance.instanceId)}
>
🗑
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete this item</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Equipment Stats Summary */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
Equipment Stats Summary
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-amber-400 game-mono">
{Object.values(store.equipmentInstances).length}
</div>
<div className="text-xs text-gray-400">Total Items</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-green-400 game-mono">
{equippedIds.size}
</div>
<div className="text-xs text-gray-400">Equipped</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-blue-400 game-mono">
{unequippedItems.length}
</div>
<div className="text-xs text-gray-400">In Inventory</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-purple-400 game-mono">
{Object.values(store.equipmentInstances).reduce(
(sum, inst) => sum + inst.enchantments.length,
0
)}
</div>
<div className="text-xs text-gray-400">Total Enchantments</div>
</div>
</div>
{/* Active Effects from Equipment */}
<div className="mt-4">
<div className="text-sm text-gray-400 mb-2">Active Effects from Equipment:</div>
<div className="flex flex-wrap gap-2">
{(() => {
const effects = store.getEquipmentEffects();
const effectEntries = Object.entries(effects).filter(([, v]) => v > 0);
if (effectEntries.length === 0) {
return <span className="text-gray-500 text-sm">No active effects</span>;
}
return effectEntries.map(([stat, value]) => (
<Badge key={stat} variant="outline" className="text-xs">
{stat}: +{fmt(value)}
</Badge>
));
})()}
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,116 @@
'use client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ELEMENTS } from '@/lib/game/constants';
interface LabTabProps {
store: {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
skills: Record<string, number>;
craftComposite: (target: string) => void;
};
}
export function LabTab({ store }: LabTabProps) {
// Render elemental mana grid
const renderElementsGrid = () => (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
{Object.entries(store.elements)
.filter(([, state]) => state.unlocked)
.map(([id, state]) => {
const def = ELEMENTS[id];
return (
<div
key={id}
className="p-2 rounded border border-gray-700 bg-gray-800/50"
>
<div className="text-lg text-center">{def?.sym}</div>
<div className="text-xs font-semibold text-center" style={{ color: def?.color }}>{def?.name}</div>
<div className="text-xs text-gray-400 game-mono text-center">{state.current}/{state.max}</div>
</div>
);
})}
</div>
);
// Render composite crafting
const renderCompositeCrafting = () => {
const compositeElements = Object.entries(ELEMENTS)
.filter(([, def]) => def.recipe)
.filter(([id]) => store.elements[id]?.unlocked);
if (compositeElements.length === 0) return null;
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Composite Crafting</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{compositeElements.map(([id, def]) => {
const recipe = def.recipe || [];
const canCraft = recipe.every(r => (store.elements[r]?.current || 0) >= 1);
const craftBonus = 1 + (store.skills.elemCrafting || 0) * 0.25;
const output = Math.floor(craftBonus);
return (
<div key={id} className="p-2 rounded border border-gray-700 bg-gray-800/50 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">{def.sym}</span>
<span className="text-sm" style={{ color: def.color }}>{def.name}</span>
<span className="text-xs text-gray-500">
({recipe.map(r => ELEMENTS[r]?.sym).join(' + ')})
</span>
</div>
<Button
size="sm"
variant="outline"
disabled={!canCraft}
onClick={() => store.craftComposite(id)}
>
Craft ({output})
</Button>
</div>
);
})}
</div>
</CardContent>
</Card>
);
};
// Check if there are any unlocked elements
const hasUnlockedElements = Object.values(store.elements).some(e => e.unlocked);
if (!hasUnlockedElements) {
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardContent className="pt-6">
<div className="text-center text-gray-500">
No elemental mana available. Elements are unlocked through gameplay.
</div>
</CardContent>
</Card>
);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Elemental Mana Display */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 text-sm">Elemental Mana</CardTitle>
</CardHeader>
<CardContent>
{renderElementsGrid()}
</CardContent>
</Card>
{/* Composite Crafting */}
{renderCompositeCrafting()}
</div>
);
}

View File

@@ -0,0 +1,369 @@
'use client';
import { useState } from 'react';
import { SKILLS_DEF, SKILL_CATEGORIES, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
import { SKILL_EVOLUTION_PATHS, getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '@/lib/game/skill-evolution';
import { getUnifiedEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
import { getAvailableSkillCategories } from '@/lib/game/data/attunements';
import { fmt, fmtDec } from '@/lib/game/store';
import { formatStudyTime } from '@/lib/game/formatting';
import type { SkillUpgradeChoice, GameStore } from '@/lib/game/types';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { StudyProgress } from './StudyProgress';
import { UpgradeDialog } from './UpgradeDialog';
import { ChevronDown, ChevronRight } from 'lucide-react';
export interface SkillsTabProps {
store: GameStore;
}
// Check if skill has milestone available
function hasMilestoneUpgrade(
skillId: string,
level: number,
skillTiers: Record<string, number>,
skillUpgrades: Record<string, string[]>
): { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null {
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
if (!path) return null;
// Check level 5 milestone
if (level >= 5) {
const upgrades5 = getUpgradesForSkillAtMilestone(skillId, 5, skillTiers);
const selected5 = (skillUpgrades[skillId] || []).filter(id => id.includes('_l5'));
if (upgrades5.length > 0 && selected5.length < 2) {
return { milestone: 5, hasUpgrades: true, selectedCount: selected5.length };
}
}
// Check level 10 milestone
if (level >= 10) {
const upgrades10 = getUpgradesForSkillAtMilestone(skillId, 10, skillTiers);
const selected10 = (skillUpgrades[skillId] || []).filter(id => id.includes('_l10'));
if (upgrades10.length > 0 && selected10.length < 2) {
return { milestone: 10, hasUpgrades: true, selectedCount: selected10.length };
}
}
return null;
}
export function SkillsTab({ store }: SkillsTabProps) {
const [upgradeDialogSkill, setUpgradeDialogSkill] = useState<string | null>(null);
const [upgradeDialogMilestone, setUpgradeDialogMilestone] = useState<5 | 10>(5);
const [pendingUpgradeSelections, setPendingUpgradeSelections] = useState<string[]>([]);
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
const studySpeedMult = getStudySpeedMultiplier(store.skills);
const upgradeEffects = getUnifiedEffects(store);
// Toggle category collapse
const toggleCategory = (categoryId: string) => {
setCollapsedCategories(prev => {
const newSet = new Set(prev);
if (newSet.has(categoryId)) {
newSet.delete(categoryId);
} else {
newSet.add(categoryId);
}
return newSet;
});
};
// Get upgrade choices for dialog
const getUpgradeChoices = () => {
if (!upgradeDialogSkill) return { available: [] as SkillUpgradeChoice[], selected: [] as string[] };
return store.getSkillUpgradeChoices(upgradeDialogSkill, upgradeDialogMilestone);
};
const { available, selected: alreadySelected } = getUpgradeChoices();
// Toggle selection
const toggleUpgrade = (upgradeId: string) => {
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
if (currentSelections.includes(upgradeId)) {
setPendingUpgradeSelections(currentSelections.filter(id => id !== upgradeId));
} else if (currentSelections.length < 2) {
setPendingUpgradeSelections([...currentSelections, upgradeId]);
}
};
// Commit selections and close
const handleConfirm = () => {
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
if (currentSelections.length === 2 && upgradeDialogSkill) {
store.commitSkillUpgrades(upgradeDialogSkill, currentSelections);
}
setPendingUpgradeSelections([]);
setUpgradeDialogSkill(null);
};
// Cancel and close
const handleCancel = () => {
setPendingUpgradeSelections([]);
setUpgradeDialogSkill(null);
};
return (
<div className="space-y-4">
{/* Upgrade Selection Dialog */}
<UpgradeDialog
open={!!upgradeDialogSkill}
skillId={upgradeDialogSkill}
milestone={upgradeDialogMilestone}
pendingSelections={pendingUpgradeSelections}
available={available}
alreadySelected={alreadySelected}
onToggle={toggleUpgrade}
onConfirm={handleConfirm}
onCancel={handleCancel}
onOpenChange={(open) => {
if (!open) {
setPendingUpgradeSelections([]);
setUpgradeDialogSkill(null);
}
}}
/>
{/* Current Study Progress */}
{store.currentStudyTarget && store.currentStudyTarget.type === 'skill' && (
<Card className="bg-gray-900/80 border-purple-600/50">
<CardContent className="pt-4">
<StudyProgress
currentStudyTarget={store.currentStudyTarget}
skills={store.skills}
studySpeedMult={studySpeedMult}
cancelStudy={store.cancelStudy}
/>
</CardContent>
</Card>
)}
{/* Get available skill categories based on attunements */}
{(() => {
const availableCategories = getAvailableSkillCategories(store.attunements || {});
return SKILL_CATEGORIES
.filter(cat => availableCategories.includes(cat.id))
.map((cat) => {
const skillsInCat = Object.entries(SKILLS_DEF).filter(([, def]) => def.cat === cat.id);
if (skillsInCat.length === 0) return null;
const isCollapsed = collapsedCategories.has(cat.id);
return (
<Card key={cat.id} className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2 cursor-pointer" onClick={() => toggleCategory(cat.id)}>
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center justify-between">
<span>{cat.icon} {cat.name}</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">{skillsInCat.length} skills</Badge>
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</div>
</CardTitle>
</CardHeader>
{!isCollapsed && (
<CardContent>
<div className="space-y-2">
{skillsInCat.map(([id, def]) => {
// Get tier info
const currentTier = store.skillTiers?.[id] || 1;
const tieredSkillId = currentTier > 1 ? `${id}_t${currentTier}` : id;
const tierMultiplier = getTierMultiplier(tieredSkillId);
// Get the actual level from the tiered skill
const level = store.skills[tieredSkillId] || store.skills[id] || 0;
const maxed = level >= def.max;
// Check if studying this skill
const isStudying = (store.currentStudyTarget?.id === id || store.currentStudyTarget?.id === tieredSkillId) && store.currentStudyTarget?.type === 'skill';
// Get tier name for display
const tierDef = SKILL_EVOLUTION_PATHS[id]?.tiers.find(t => t.tier === currentTier);
const skillDisplayName = tierDef?.name || def.name;
// Check prerequisites
let prereqMet = true;
if (def.req) {
for (const [r, rl] of Object.entries(def.req)) {
if ((store.skills[r] || 0) < rl) {
prereqMet = false;
break;
}
}
}
// Apply skill modifiers
const costMult = getStudyCostMultiplier(store.skills);
const speedMult = getStudySpeedMultiplier(store.skills);
const studyEffects = getUnifiedEffects(store);
const effectiveSpeedMult = speedMult * studyEffects.studySpeedMultiplier;
// Study time scales with tier
const tierStudyTime = def.studyTime * currentTier;
const effectiveStudyTime = tierStudyTime / effectiveSpeedMult;
// Cost scales with tier
const baseCost = def.base * (level + 1) * currentTier;
const cost = Math.floor(baseCost * costMult);
// Can start studying?
const canStudy = !maxed && prereqMet && store.rawMana >= cost && !isStudying;
// Check for milestone upgrades
const milestoneInfo = hasMilestoneUpgrade(tieredSkillId, level, store.skillTiers || {}, store.skillUpgrades);
// Check for tier up
const nextTierSkill = getNextTierSkill(tieredSkillId);
const canTierUp = maxed && nextTierSkill;
// Get selected upgrades
const selectedUpgrades = store.skillUpgrades[tieredSkillId] || [];
const selectedL5 = selectedUpgrades.filter(u => u.includes('_l5'));
const selectedL10 = selectedUpgrades.filter(u => u.includes('_l10'));
return (
<div
key={id}
className={`flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded border gap-2 ${
isStudying ? 'border-purple-500 bg-purple-900/20' :
milestoneInfo ? 'border-amber-500/50 bg-amber-900/10' :
'border-gray-700 bg-gray-800/30'
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm">{skillDisplayName}</span>
{currentTier > 1 && (
<Badge className="bg-purple-600/50 text-purple-200 text-xs">Tier {currentTier} ({fmtDec(tierMultiplier, 0)}x)</Badge>
)}
{level > 0 && <span className="text-purple-400 text-sm">Lv.{level}</span>}
{selectedUpgrades.length > 0 && (
<div className="flex gap-1">
{selectedL5.length > 0 && (
<Badge className="bg-amber-700/50 text-amber-200 text-xs">L5: {selectedL5.length}</Badge>
)}
{selectedL10.length > 0 && (
<Badge className="bg-purple-700/50 text-purple-200 text-xs">L10: {selectedL10.length}</Badge>
)}
</div>
)}
</div>
<div className="text-xs text-gray-400 italic">{def.desc}{currentTier > 1 && ` (Tier ${currentTier}: ${fmtDec(tierMultiplier, 0)}x effect)`}</div>
{!prereqMet && def.req && (
<div className="text-xs text-red-400 mt-1">
Requires: {Object.entries(def.req).map(([r, rl]) => `${SKILLS_DEF[r]?.name} Lv.${rl}`).join(', ')}
</div>
)}
<div className="text-xs text-gray-500 mt-1">
<span className={effectiveSpeedMult > 1 ? 'text-green-400' : ''}>
Study: {formatStudyTime(effectiveStudyTime)}{effectiveSpeedMult > 1 && <span className="text-xs ml-1">({Math.round(effectiveSpeedMult * 100)}% speed)</span>}
</span>
{' • '}
<span className={costMult < 1 ? 'text-green-400' : ''}>
Cost: {fmt(cost)} mana{costMult < 1 && <span className="text-xs ml-1">({Math.round(costMult * 100)}% cost)</span>}
</span>
</div>
{milestoneInfo && (
<div className="text-xs text-amber-400 mt-1 flex items-center gap-1">
Level {milestoneInfo.milestone} milestone: {milestoneInfo.selectedCount}/2 upgrades selected
</div>
)}
</div>
<div className="flex items-center gap-3 flex-wrap sm:flex-nowrap">
{/* Level dots */}
<div className="flex gap-1 shrink-0">
{Array.from({ length: def.max }).map((_, i) => (
<div
key={i}
className={`w-2 h-2 rounded-full border ${
i < level ? 'bg-purple-500 border-purple-400' :
i === 4 || i === 9 ? 'border-amber-500' :
'border-gray-600'
}`}
/>
))}
</div>
{isStudying ? (
<div className="text-xs text-purple-400">
{formatStudyTime(store.currentStudyTarget?.progress || 0)}/{formatStudyTime(tierStudyTime)}
</div>
) : milestoneInfo ? (
<Button
size="sm"
className="bg-amber-600 hover:bg-amber-700"
onClick={() => {
setUpgradeDialogSkill(tieredSkillId);
setUpgradeDialogMilestone(milestoneInfo.milestone);
}}
>
Choose Upgrades
</Button>
) : canTierUp ? (
<Button
size="sm"
className="bg-purple-600 hover:bg-purple-700"
onClick={() => store.tierUpSkill(tieredSkillId)}
>
Tier Up
</Button>
) : maxed ? (
<Badge className="bg-green-900/50 text-green-300">Maxed</Badge>
) : (
<div className="flex gap-1">
<Button
size="sm"
variant={canStudy ? 'default' : 'outline'}
disabled={!canStudy}
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
onClick={() => store.startStudyingSkill(tieredSkillId)}
>
Study ({fmt(cost)})
</Button>
{/* Parallel Study button */}
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.PARALLEL_STUDY) &&
store.currentStudyTarget &&
!store.parallelStudyTarget &&
store.currentStudyTarget.id !== tieredSkillId &&
canStudy && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="border-cyan-500 text-cyan-400 hover:bg-cyan-900/30"
onClick={() => store.startParallelStudySkill(tieredSkillId)}
>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Study in parallel (50% speed)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</CardContent>
)}
</Card>
);
});
})()}
</div>
);
}

View File

@@ -0,0 +1,180 @@
'use client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ELEMENTS, SPELLS_DEF } from '@/lib/game/constants';
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
import { calcDamage, canAffordSpellCost, fmt } from '@/lib/game/store';
import { formatSpellCost, getSpellCostColor } from '@/lib/game/formatting';
interface SpellsTabProps {
store: {
spells: Record<string, { learned: boolean; level: number; studyProgress: number }>;
equippedInstances: Record<string, string | null>;
equipmentInstances: Record<string, { instanceId: string; name: string; enchantments: { effectId: string; stacks: number }[] }>;
activeSpell: string;
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
signedPacts: number[];
unlockedEffects: string[];
setSpell: (spellId: string) => void;
};
}
export function SpellsTab({ store }: SpellsTabProps) {
// Get spells from equipment
const equipmentSpellIds: string[] = [];
const spellSources: Record<string, string[]> = {};
for (const instanceId of Object.values(store.equippedInstances)) {
if (!instanceId) continue;
const instance = store.equipmentInstances[instanceId];
if (!instance) continue;
for (const ench of instance.enchantments) {
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) {
const spellId = effectDef.effect.spellId;
if (!equipmentSpellIds.includes(spellId)) {
equipmentSpellIds.push(spellId);
}
if (!spellSources[spellId]) {
spellSources[spellId] = [];
}
spellSources[spellId].push(instance.name);
}
}
}
const canCastSpell = (spellId: string): boolean => {
const spell = SPELLS_DEF[spellId];
if (!spell) return false;
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
};
return (
<div className="space-y-6">
{/* Equipment-Granted Spells */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3 text-cyan-400"> Known Spells</h3>
<p className="text-sm text-gray-400 mb-4">
Spells are obtained by enchanting equipment with spell effects.
Visit the Crafting tab to design and apply enchantments.
</p>
{equipmentSpellIds.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{equipmentSpellIds.map(id => {
const def = SPELLS_DEF[id];
if (!def) return null;
const isActive = store.activeSpell === id;
const canCast = canCastSpell(id);
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
const sources = spellSources[id] || [];
return (
<Card
key={id}
className={`bg-gray-900/80 border-cyan-600/50 ${canCast ? 'ring-1 ring-green-500/30' : ''}`}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
{def.name}
</CardTitle>
<Badge className="bg-cyan-900/50 text-cyan-300 text-xs">Equipment</Badge>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-xs text-gray-400">
{def.elem !== 'raw' && <span className="mr-2">{elemDef?.sym} {elemDef?.name}</span>}
<span> {def.dmg} dmg</span>
</div>
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
Cost: {formatSpellCost(def.cost)}
</div>
<div className="text-xs text-cyan-400/70">From: {sources.join(', ')}</div>
<div className="flex gap-2">
{isActive ? (
<Badge className="bg-amber-900/50 text-amber-300">Active</Badge>
) : (
<Button size="sm" variant="outline" onClick={() => store.setSpell(id)}>
Set Active
</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
) : (
<div className="text-center p-8 bg-gray-800/30 rounded border border-gray-700">
<div className="text-gray-500 mb-2">No spells known yet</div>
<div className="text-sm text-gray-600">Enchant a staff with a spell effect to gain spells</div>
</div>
)}
</div>
{/* Pact Spells (from guardian defeats) */}
{store.signedPacts.length > 0 && (
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3 text-amber-400">🏆 Pact Spells</h3>
<p className="text-sm text-gray-400 mb-3">Spells earned through guardian pacts appear here.</p>
</div>
)}
{/* Spell Reference - show all available spells for enchanting */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3 text-purple-400">📚 Spell Reference</h3>
<p className="text-sm text-gray-400 mb-4">
These spells can be applied to equipment through the enchanting system.
Research enchantment effects in the Skills tab to unlock them for designing.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(SPELLS_DEF).map(([id, def]) => {
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
const isUnlocked = store.unlockedEffects?.includes(`spell_${id}`);
return (
<Card
key={id}
className={`bg-gray-900/80 border-gray-700 ${isUnlocked ? 'border-purple-500/50' : 'opacity-60'}`}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
{def.name}
</CardTitle>
<div className="flex gap-1">
{def.tier > 0 && <Badge variant="outline" className="text-xs">T{def.tier}</Badge>}
{isUnlocked && <Badge className="bg-purple-900/50 text-purple-300 text-xs">Unlocked</Badge>}
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-xs text-gray-400">
{def.elem !== 'raw' && <span className="mr-2">{elemDef?.sym} {elemDef?.name}</span>}
<span> {def.dmg} dmg</span>
</div>
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
Cost: {formatSpellCost(def.cost)}
</div>
{def.desc && (
<div className="text-xs text-gray-500 italic">{def.desc}</div>
)}
{!isUnlocked && (
<div className="text-xs text-amber-400/70">Research to unlock for enchanting</div>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,288 @@
'use client';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Swords, BookOpen, ChevronUp, ChevronDown, RotateCcw, X } from 'lucide-react';
import type { GameStore } from '@/lib/game/types';
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF } from '@/lib/game/constants';
import { fmt, fmtDec, getFloorElement, canAffordSpellCost } from '@/lib/game/store';
import { getActiveEquipmentSpells, getTotalDPS } from '@/lib/game/computed-stats';
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
import { CraftingProgress, StudyProgress } from '@/components/game';
import { getUnifiedEffects } from '@/lib/game/effects';
interface SpireTabProps {
store: GameStore;
}
export function SpireTab({ store }: SpireTabProps) {
const floorElem = getFloorElement(store.currentFloor);
const floorElemDef = ELEMENTS[floorElem];
const isGuardianFloor = !!GUARDIANS[store.currentFloor];
const currentGuardian = GUARDIANS[store.currentFloor];
const climbDirection = store.climbDirection || 'up';
const clearedFloors = store.clearedFloors || {};
// Check if current floor is cleared (for respawn indicator)
const isFloorCleared = clearedFloors[store.currentFloor];
// Get active equipment spells
const activeEquipmentSpells = getActiveEquipmentSpells(store.equippedInstances, store.equipmentInstances);
// Get upgrade effects and DPS
const upgradeEffects = getUnifiedEffects(store);
const totalDPS = getTotalDPS(store, upgradeEffects, floorElem);
const studySpeedMult = 1; // Base study speed
const canCastSpell = (spellId: string): boolean => {
const spell = SPELLS_DEF[spellId];
if (!spell) return false;
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
};
return (
<TooltipProvider>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Current Floor Card */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Current Floor</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold game-title" style={{ color: floorElemDef?.color }}>
{store.currentFloor}
</span>
<span className="text-gray-400 text-sm">/ 100</span>
<span className="ml-auto text-sm" style={{ color: floorElemDef?.color }}>
{floorElemDef?.sym} {floorElemDef?.name}
</span>
{isGuardianFloor && (
<Badge className="bg-red-900/50 text-red-300 border-red-600">GUARDIAN</Badge>
)}
</div>
{isGuardianFloor && currentGuardian && (
<div className="text-sm font-semibold game-panel-title" style={{ color: floorElemDef?.color }}>
{currentGuardian.name}
</div>
)}
{/* HP Bar */}
<div className="space-y-1">
<div className="h-3 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.max(0, (store.floorHP / store.floorMaxHP) * 100)}%`,
background: `linear-gradient(90deg, ${floorElemDef?.color}99, ${floorElemDef?.color})`,
boxShadow: `0 0 10px ${floorElemDef?.glow}`,
}}
/>
</div>
<div className="flex justify-between text-xs text-gray-400 game-mono">
<span>{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP</span>
<span>DPS: {store.currentAction === 'climb' && activeEquipmentSpells.length > 0 ? fmtDec(totalDPS) : '—'}</span>
</div>
</div>
<Separator className="bg-gray-700" />
{/* Floor Navigation - Direction indicator only */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">Direction</span>
<div className="flex gap-1">
<Badge variant={climbDirection === 'up' ? 'default' : 'outline'}
className={climbDirection === 'up' ? 'bg-green-600' : ''}>
<ChevronUp className="w-3 h-3 mr-1" />
Up
</Badge>
<Badge variant={climbDirection === 'down' ? 'default' : 'outline'}
className={climbDirection === 'down' ? 'bg-blue-600' : ''}>
<ChevronDown className="w-3 h-3 mr-1" />
Down
</Badge>
</div>
</div>
{isFloorCleared && (
<div className="text-xs text-amber-400 text-center flex items-center justify-center gap-1">
<RotateCcw className="w-3 h-3" />
Floor cleared! Advancing...
</div>
)}
</div>
<Separator className="bg-gray-700" />
<div className="text-sm text-gray-400">
Best: Floor <strong className="text-gray-200">{store.maxFloorReached}</strong>
Pacts: <strong className="text-amber-400">{store.signedPacts.length}</strong>
</div>
</CardContent>
</Card>
{/* Active Spells Card - Shows all spells from equipped weapons */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
Active Spells ({activeEquipmentSpells.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{activeEquipmentSpells.length > 0 ? (
<div className="space-y-3">
{activeEquipmentSpells.map(({ spellId, equipmentId }) => {
const spellDef = SPELLS_DEF[spellId];
if (!spellDef) return null;
const spellState = store.equipmentSpellStates?.find(
s => s.spellId === spellId && s.sourceEquipment === equipmentId
);
const progress = spellState?.castProgress || 0;
const canCast = canAffordSpellCost(spellDef.cost, store.rawMana, store.elements);
return (
<div key={`${spellId}-${equipmentId}`} className="p-2 rounded border border-gray-700 bg-gray-800/30">
<div className="flex items-center justify-between mb-1">
<div className="text-sm font-semibold game-panel-title" style={{ color: spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color }}>
{spellDef.name}
{spellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200 text-xs">Basic</Badge>}
{spellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100 text-xs">Legendary</Badge>}
</div>
<span className={`text-xs ${canCast ? 'text-green-400' : 'text-red-400'}`}>
{canCast ? '✓' : '✗'}
</span>
</div>
<div className="text-xs text-gray-400 game-mono mb-1">
{fmt(totalDPS)} DPS
<span style={{ color: getSpellCostColor(spellDef.cost) }}>
{' '}{formatSpellCost(spellDef.cost)}
</span>
{' '} {(spellDef.castSpeed || 1).toFixed(1)}/hr
</div>
{/* Cast progress bar when climbing */}
{store.currentAction === 'climb' && (
<div className="space-y-0.5">
<div className="flex justify-between text-xs text-gray-500">
<span>Cast</span>
<span>{(progress * 100).toFixed(0)}%</span>
</div>
<Progress value={Math.min(100, progress * 100)} className="h-1.5 bg-gray-700" />
</div>
)}
{spellDef.effects && spellDef.effects.length > 0 && (
<div className="flex gap-1 flex-wrap mt-1">
{spellDef.effects.map((eff, i) => (
<Badge key={i} variant="outline" className="text-xs py-0">
{eff.type === 'lifesteal' && `🩸 ${Math.round(eff.value * 100)}%`}
{eff.type === 'burn' && `🔥 Burn`}
{eff.type === 'freeze' && `❄️ Freeze`}
</Badge>
))}
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-gray-500 text-sm">No spells on equipped weapons. Enchant a staff with spell effects in the Crafting tab.</div>
)}
</CardContent>
</Card>
{/* Current Study (if any) */}
{store.currentStudyTarget && (
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
<CardContent className="pt-4 space-y-3">
<StudyProgress
currentStudyTarget={store.currentStudyTarget}
skills={store.skills}
studySpeedMult={studySpeedMult}
cancelStudy={store.cancelStudy}
/>
{/* Parallel Study Progress */}
{store.parallelStudyTarget && (
<div className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-cyan-400" />
<span className="text-sm font-semibold text-cyan-300">
Parallel: {SKILLS_DEF[store.parallelStudyTarget.id]?.name}
{store.parallelStudyTarget.type === 'skill' && ` Lv.${(store.skills[store.parallelStudyTarget.id] || 0) + 1}`}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
onClick={() => store.cancelParallelStudy?.()}
>
<X className="w-4 h-4" />
</Button>
</div>
<Progress value={Math.min(100, (store.parallelStudyTarget.progress / store.parallelStudyTarget.required) * 100)} className="h-2 bg-gray-800" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{formatStudyTime(store.parallelStudyTarget.progress)} / {formatStudyTime(store.parallelStudyTarget.required)}</span>
<span>50% speed (Parallel Study)</span>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Crafting Progress (if any) */}
{(store.designProgress || store.preparationProgress || store.applicationProgress) && (
<Card className="bg-gray-900/80 border-cyan-600/50 lg:col-span-2">
<CardContent className="pt-4">
<CraftingProgress
designProgress={store.designProgress}
preparationProgress={store.preparationProgress}
applicationProgress={store.applicationProgress}
equipmentInstances={store.equipmentInstances}
enchantmentDesigns={store.enchantmentDesigns}
cancelDesign={store.cancelDesign!}
cancelPreparation={store.cancelPreparation!}
pauseApplication={store.pauseApplication!}
resumeApplication={store.resumeApplication!}
cancelApplication={store.cancelApplication!}
/>
</CardContent>
</Card>
)}
{/* Activity Log */}
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Activity Log</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-32">
<div className="space-y-1">
{store.log.slice(0, 20).map((entry, i) => (
<div
key={i}
className={`text-sm ${i === 0 ? 'text-gray-200' : 'text-gray-500'} italic`}
>
{entry}
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,545 @@
'use client';
import { ELEMENTS, GUARDIANS, SKILLS_DEF } from '@/lib/game/constants';
import { SKILL_EVOLUTION_PATHS, getTierMultiplier } from '@/lib/game/skill-evolution';
import { hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
import { fmt, fmtDec, calcDamage } from '@/lib/game/store';
import type { SkillUpgradeChoice, GameStore, UnifiedEffects } from '@/lib/game/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Droplet, Swords, BookOpen, FlaskConical, Trophy, RotateCcw, Star } from 'lucide-react';
export interface StatsTabProps {
store: GameStore;
upgradeEffects: UnifiedEffects;
maxMana: number;
baseRegen: number;
clickMana: number;
meditationMultiplier: number;
effectiveRegen: number;
incursionStrength: number;
manaCascadeBonus: number;
studySpeedMult: number;
studyCostMult: number;
}
export function StatsTab({
store,
upgradeEffects,
maxMana,
baseRegen,
clickMana,
meditationMultiplier,
effectiveRegen,
incursionStrength,
manaCascadeBonus,
studySpeedMult,
studyCostMult,
}: StatsTabProps) {
// Compute element max
const elemMax = (() => {
const ea = store.skillTiers?.elemAttune || 1;
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
const tierMult = getTierMultiplier(tieredSkillId);
return 10 + level * 50 * tierMult + (store.prestigeUpgrades.elementalAttune || 0) * 25;
})();
// Get all selected skill upgrades
const getAllSelectedUpgrades = () => {
const upgrades: { skillId: string; upgrade: SkillUpgradeChoice }[] = [];
for (const [skillId, selectedIds] of Object.entries(store.skillUpgrades)) {
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
if (!path) continue;
for (const tier of path.tiers) {
if (tier.skillId === skillId) {
for (const upgradeId of selectedIds) {
const upgrade = tier.upgrades.find(u => u.id === upgradeId);
if (upgrade) {
upgrades.push({ skillId, upgrade });
}
}
}
}
}
return upgrades;
};
const selectedUpgrades = getAllSelectedUpgrades();
return (
<div className="space-y-4">
{/* Mana Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-blue-400 game-panel-title text-xs flex items-center gap-2">
<Droplet className="w-4 h-4" />
Mana Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Base Max Mana:</span>
<span className="text-gray-200">100</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Well Bonus:</span>
<span className="text-blue-300">
{(() => {
const mw = store.skillTiers?.manaWell || 1;
const tieredSkillId = mw > 1 ? `manaWell_t${mw}` : 'manaWell';
const level = store.skills[tieredSkillId] || store.skills.manaWell || 0;
const tierMult = getTierMultiplier(tieredSkillId);
return `+${fmt(level * 100 * tierMult)} (${level} lvl × 100 × ${tierMult}x tier)`;
})()}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Prestige Mana Well:</span>
<span className="text-blue-300">+{fmt((store.prestigeUpgrades.manaWell || 0) * 500)}</span>
</div>
{upgradeEffects.maxManaBonus > 0 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Upgrade Mana Bonus:</span>
<span className="text-amber-300">+{fmt(upgradeEffects.maxManaBonus)}</span>
</div>
)}
{upgradeEffects.maxManaMultiplier > 1 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Upgrade Mana Multiplier:</span>
<span className="text-amber-300">×{fmtDec(upgradeEffects.maxManaMultiplier, 2)}</span>
</div>
)}
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
<span className="text-gray-300">Total Max Mana:</span>
<span className="text-blue-400">{fmt(maxMana)}</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Base Regen:</span>
<span className="text-gray-200">2/hr</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Flow Bonus:</span>
<span className="text-blue-300">
{(() => {
const mf = store.skillTiers?.manaFlow || 1;
const tieredSkillId = mf > 1 ? `manaFlow_t${mf}` : 'manaFlow';
const level = store.skills[tieredSkillId] || store.skills.manaFlow || 0;
const tierMult = getTierMultiplier(tieredSkillId);
return `+${fmtDec(level * 1 * tierMult)}/hr (${level} lvl × 1 × ${tierMult}x tier)`;
})()}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Spring Bonus:</span>
<span className="text-blue-300">+{(store.skills.manaSpring || 0) * 2}/hr</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Prestige Mana Flow:</span>
<span className="text-blue-300">+{fmtDec((store.prestigeUpgrades.manaFlow || 0) * 0.5)}/hr</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Temporal Echo:</span>
<span className="text-blue-300">×{fmtDec(1 + (store.prestigeUpgrades.temporalEcho || 0) * 0.1, 2)}</span>
</div>
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
<span className="text-gray-300">Base Regen:</span>
<span className="text-blue-400">{fmtDec(baseRegen, 2)}/hr</span>
</div>
{upgradeEffects.regenBonus > 0 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Upgrade Regen Bonus:</span>
<span className="text-amber-300">+{fmtDec(upgradeEffects.regenBonus, 2)}/hr</span>
</div>
)}
{upgradeEffects.permanentRegenBonus > 0 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Permanent Regen Bonus:</span>
<span className="text-amber-300">+{fmtDec(upgradeEffects.permanentRegenBonus, 2)}/hr</span>
</div>
)}
{upgradeEffects.regenMultiplier > 1 && (
<div className="flex justify-between text-sm">
<span className="text-amber-400">Upgrade Regen Multiplier:</span>
<span className="text-amber-300">×{fmtDec(upgradeEffects.regenMultiplier, 2)}</span>
</div>
)}
</div>
</div>
<Separator className="bg-gray-700 my-3" />
{upgradeEffects.activeUpgrades.length > 0 && (
<>
<div className="mb-2">
<span className="text-xs text-amber-400 game-panel-title">Active Skill Upgrades</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-3">
{upgradeEffects.activeUpgrades.map((upgrade, idx) => (
<div key={idx} className="flex justify-between text-xs bg-gray-800/50 rounded px-2 py-1">
<span className="text-gray-300">{upgrade.name}</span>
<span className="text-gray-400">{upgrade.desc}</span>
</div>
))}
</div>
<Separator className="bg-gray-700 my-3" />
</>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Click Mana Value:</span>
<span className="text-purple-300">+{clickMana}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Tap Bonus:</span>
<span className="text-purple-300">+{store.skills.manaTap || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Surge Bonus:</span>
<span className="text-purple-300">+{(store.skills.manaSurge || 0) * 3}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Mana Overflow:</span>
<span className="text-purple-300">×{fmtDec(1 + (store.skills.manaOverflow || 0) * 0.25, 2)}</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Meditation Multiplier:</span>
<span className={`font-semibold ${meditationMultiplier > 1.5 ? 'text-purple-400' : 'text-gray-300'}`}>
{fmtDec(meditationMultiplier, 2)}x
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Effective Regen:</span>
<span className="text-green-400 font-semibold">{fmtDec(effectiveRegen, 2)}/hr</span>
</div>
{incursionStrength > 0 && !hasSpecial(upgradeEffects, SPECIAL_EFFECTS.STEADY_STREAM) && (
<div className="flex justify-between text-sm">
<span className="text-red-400">Incursion Penalty:</span>
<span className="text-red-400">-{Math.round(incursionStrength * 100)}%</span>
</div>
)}
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.STEADY_STREAM) && incursionStrength > 0 && (
<div className="flex justify-between text-sm">
<span className="text-green-400">Steady Stream:</span>
<span className="text-green-400">Immune to incursion</span>
</div>
)}
{manaCascadeBonus > 0 && (
<div className="flex justify-between text-sm">
<span className="text-cyan-400">Mana Cascade Bonus:</span>
<span className="text-cyan-400">+{fmtDec(manaCascadeBonus, 2)}/hr</span>
</div>
)}
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_TORRENT) && store.rawMana > maxMana * 0.75 && (
<div className="flex justify-between text-sm">
<span className="text-cyan-400">Mana Torrent:</span>
<span className="text-cyan-400">+50% regen (high mana)</span>
</div>
)}
{hasSpecial(upgradeEffects, SPECIAL_EFFECTS.DESPERATE_WELLS) && store.rawMana < maxMana * 0.25 && (
<div className="flex justify-between text-sm">
<span className="text-cyan-400">Desperate Wells:</span>
<span className="text-cyan-400">+50% regen (low mana)</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Combat Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-red-400 game-panel-title text-xs flex items-center gap-2">
<Swords className="w-4 h-4" />
Combat Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Combat Training Bonus:</span>
<span className="text-red-300">+{(store.skills.combatTrain || 0) * 5}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Arcane Fury Multiplier:</span>
<span className="text-red-300">×{fmtDec(1 + (store.skills.arcaneFury || 0) * 0.1, 2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Elemental Mastery:</span>
<span className="text-red-300">×{fmtDec(1 + (store.skills.elementalMastery || 0) * 0.15, 2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Guardian Bane:</span>
<span className="text-red-300">×{fmtDec(1 + (store.skills.guardianBane || 0) * 0.2, 2)} (vs guardians)</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Critical Hit Chance:</span>
<span className="text-amber-300">{((store.skills.precision || 0) * 5)}%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Critical Multiplier:</span>
<span className="text-amber-300">1.5x</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Spell Echo Chance:</span>
<span className="text-amber-300">{((store.skills.spellEcho || 0) * 10)}%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Pact Multiplier:</span>
<span className="text-amber-300">×{fmtDec(store.signedPacts.reduce((m, f) => m * (GUARDIANS[f]?.pact || 1), 1), 2)}</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Study Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
<BookOpen className="w-4 h-4" />
Study Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Study Speed:</span>
<span className="text-purple-300">×{fmtDec(studySpeedMult, 2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Quick Learner Bonus:</span>
<span className="text-purple-300">+{((store.skills.quickLearner || 0) * 10)}%</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Study Cost:</span>
<span className="text-purple-300">{Math.round(studyCostMult * 100)}%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Focused Mind Bonus:</span>
<span className="text-purple-300">-{((store.skills.focusedMind || 0) * 5)}%</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Progress Retention:</span>
<span className="text-purple-300">{Math.round((1 + (store.skills.knowledgeRetention || 0) * 0.2) * 100)}%</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Element Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-green-400 game-panel-title text-xs flex items-center gap-2">
<FlaskConical className="w-4 h-4" />
Element Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Element Capacity:</span>
<span className="text-green-300">{elemMax}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Elem. Attunement Bonus:</span>
<span className="text-green-300">
{(() => {
const ea = store.skillTiers?.elemAttune || 1;
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
const tierMult = getTierMultiplier(tieredSkillId);
return `+${level * 50 * tierMult}`;
})()}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Prestige Attunement:</span>
<span className="text-green-300">+{(store.prestigeUpgrades.elementalAttune || 0) * 25}</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Unlocked Elements:</span>
<span className="text-green-300">{Object.values(store.elements).filter(e => e.unlocked).length} / {Object.keys(ELEMENTS).length}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Elem. Crafting Bonus:</span>
<span className="text-green-300">×{fmtDec(1 + (store.skills.elemCrafting || 0) * 0.25, 2)}</span>
</div>
</div>
</div>
<Separator className="bg-gray-700 my-3" />
<div className="text-xs text-gray-400 mb-2">Elemental Mana Pools:</div>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
{Object.entries(store.elements)
.filter(([, state]) => state.unlocked)
.map(([id, state]) => {
const def = ELEMENTS[id];
return (
<div key={id} className="p-2 rounded border border-gray-700 bg-gray-800/50 text-center">
<div className="text-lg">{def?.sym}</div>
<div className="text-xs text-gray-400">{state.current}/{state.max}</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Active Upgrades */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
<Star className="w-4 h-4" />
Active Skill Upgrades ({selectedUpgrades.length})
</CardTitle>
</CardHeader>
<CardContent>
{selectedUpgrades.length === 0 ? (
<div className="text-gray-500 text-sm">No skill upgrades selected yet. Level skills to 5 or 10 to choose upgrades.</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{selectedUpgrades.map(({ skillId, upgrade }) => (
<div key={upgrade.id} className="p-2 rounded border border-amber-600/30 bg-amber-900/10">
<div className="flex items-center justify-between">
<span className="text-amber-300 text-sm font-semibold">{upgrade.name}</span>
<Badge variant="outline" className="text-xs text-gray-400">
{SKILLS_DEF[skillId]?.name || skillId}
</Badge>
</div>
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
{upgrade.effect.type === 'multiplier' && (
<div className="text-xs text-green-400 mt-1">
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'bonus' && (
<div className="text-xs text-blue-400 mt-1">
+{upgrade.effect.value} {upgrade.effect.stat}
</div>
)}
{upgrade.effect.type === 'special' && (
<div className="text-xs text-cyan-400 mt-1">
{upgrade.effect.specialDesc || 'Special effect active'}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Pact Bonuses */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
<Trophy className="w-4 h-4" />
Signed Pacts ({store.signedPacts.length}/10)
</CardTitle>
</CardHeader>
<CardContent>
{store.signedPacts.length === 0 ? (
<div className="text-gray-500 text-sm">No pacts signed yet. Defeat guardians to earn pacts.</div>
) : (
<div className="space-y-2">
{store.signedPacts.map((floor) => {
const guardian = GUARDIANS[floor];
if (!guardian) return null;
return (
<div
key={floor}
className="flex items-center justify-between p-2 rounded border"
style={{ borderColor: guardian.color, backgroundColor: `${guardian.color}15` }}
>
<div>
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
{guardian.name}
</div>
<div className="text-xs text-gray-400">Floor {floor}</div>
</div>
<Badge className="bg-amber-900/50 text-amber-300">
{guardian.pact}x multiplier
</Badge>
</div>
);
})}
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2 mt-2">
<span className="text-gray-300">Combined Pact Multiplier:</span>
<span className="text-amber-400">×{fmtDec(store.signedPacts.reduce((m, f) => m * (GUARDIANS[f]?.pact || 1), 1), 2)}</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* Loop Stats */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
<RotateCcw className="w-4 h-4" />
Loop Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
<div className="text-xs text-gray-400">Loops Completed</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
<div className="text-xs text-gray-400">Current Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
<div className="text-xs text-gray-400">Total Insight</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-green-400 game-mono">{store.maxFloorReached}</div>
<div className="text-xs text-gray-400">Max Floor</div>
</div>
</div>
<Separator className="bg-gray-700 my-3" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-xl font-bold text-gray-300 game-mono">{Object.values(store.spells).filter(s => s.learned).length}</div>
<div className="text-xs text-gray-400">Spells Learned</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-xl font-bold text-gray-300 game-mono">{Object.values(store.skills).reduce((a, b) => a + b, 0)}</div>
<div className="text-xs text-gray-400">Total Skill Levels</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-xl font-bold text-gray-300 game-mono">{fmt(store.totalManaGathered)}</div>
<div className="text-xs text-gray-400">Total Mana Gathered</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-xl font-bold text-gray-300 game-mono">{store.memorySlots}</div>
<div className="text-xs text-gray-400">Memory Slots</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { SKILLS_DEF } from '@/lib/game/constants';
import { formatStudyTime } from '@/lib/game/formatting';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import type { StudyTarget } from '@/lib/game/types';
export interface StudyProgressProps {
currentStudyTarget: StudyTarget;
skills: Record<string, number>;
studySpeedMult: number;
cancelStudy: () => void;
}
export function StudyProgress({
currentStudyTarget,
skills,
studySpeedMult,
cancelStudy
}: StudyProgressProps) {
const { id, progress, required } = currentStudyTarget;
// Get skill name
const baseId = id.includes('_t') ? id.split('_t')[0] : id;
const skillDef = SKILLS_DEF[baseId];
const skillName = skillDef?.name || id;
// Get current level
const currentLevel = skills[id] || skills[baseId] || 0;
// Calculate progress percentage
const progressPercent = Math.min((progress / required) * 100, 100);
// Estimated completion
const remainingHours = required - progress;
const effectiveSpeed = studySpeedMult;
const realTimeRemaining = remainingHours / effectiveSpeed;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<span className="text-purple-300 font-semibold">{skillName}</span>
<span className="text-gray-400 ml-2">
Level {currentLevel} {currentLevel + 1}
</span>
</div>
<Button
size="sm"
variant="destructive"
onClick={cancelStudy}
className="text-xs"
>
Cancel
</Button>
</div>
<div className="space-y-1">
<div className="flex justify-between text-xs text-gray-400">
<span>{formatStudyTime(progress)} / {formatStudyTime(required)}</span>
<span>{progressPercent.toFixed(1)}%</span>
</div>
<Progress value={progressPercent} className="h-2" />
{studySpeedMult > 1 && (
<div className="text-xs text-green-400">
Speed: {(studySpeedMult * 100).toFixed(0)}% ETA: {formatStudyTime(realTimeRemaining)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
'use client';
import { SKILLS_DEF } from '@/lib/game/constants';
import { getUpgradesForSkillAtMilestone, getTierMultiplier } from '@/lib/game/skill-evolution';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import type { SkillUpgradeChoice } from '@/lib/game/types';
export interface UpgradeDialogProps {
open: boolean;
skillId: string | null;
milestone: 5 | 10;
pendingSelections: string[];
available: SkillUpgradeChoice[];
alreadySelected: string[];
onToggle: (upgradeId: string) => void;
onConfirm: () => void;
onCancel: () => void;
onOpenChange: (open: boolean) => void;
}
export function UpgradeDialog({
open,
skillId,
milestone,
pendingSelections,
available,
alreadySelected,
onToggle,
onConfirm,
onCancel,
onOpenChange,
}: UpgradeDialogProps) {
if (!skillId) return null;
// Get skill name
const baseId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
const skillDef = SKILLS_DEF[baseId];
const skillName = skillDef?.name || skillId;
const currentSelections = pendingSelections.length > 0 ? pendingSelections : alreadySelected;
const canConfirm = currentSelections.length === 2;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-gray-900 border-purple-600/50">
<DialogHeader>
<DialogTitle className="text-amber-400">
Level {milestone} Milestone: {skillName}
</DialogTitle>
<DialogDescription className="text-gray-400">
Choose 2 upgrades for this skill. These choices are permanent.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-4">
{available.map((upgrade) => {
const isSelected = currentSelections.includes(upgrade.id);
const canSelect = isSelected || currentSelections.length < 2;
return (
<div
key={upgrade.id}
onClick={() => canSelect && onToggle(upgrade.id)}
className={`p-3 rounded border cursor-pointer transition-all ${
isSelected
? 'border-amber-500 bg-amber-900/30'
: canSelect
? 'border-gray-700 hover:border-gray-500 bg-gray-800/30'
: 'border-gray-800 bg-gray-900/30 opacity-50 cursor-not-allowed'
}`}
>
<div className="flex items-center justify-between">
<span className={`font-semibold text-sm ${isSelected ? 'text-amber-300' : 'text-gray-200'}`}>
{upgrade.name}
</span>
{isSelected && (
<Badge className="bg-amber-600/50 text-amber-200 text-xs">Selected</Badge>
)}
</div>
<p className="text-xs text-gray-400 mt-1">{upgrade.desc}</p>
</div>
);
})}
{available.length === 0 && (
<div className="text-center text-gray-500 py-4">
No upgrades available at this milestone.
</div>
)}
</div>
<DialogFooter className="flex gap-2 sm:gap-0">
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button
className="bg-amber-600 hover:bg-amber-700"
disabled={!canConfirm}
onClick={onConfirm}
>
Confirm ({currentSelections.length}/2)
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,12 @@
// ─── Tab Components Index ──────────────────────────────────────────────────────
// Re-exports all tab components for cleaner imports
export { CraftingTab } from './CraftingTab';
export { SpireTab } from './SpireTab';
export { SpellsTab } from './SpellsTab';
export { LabTab } from './LabTab';
export { SkillsTab } from './SkillsTab';
export { StatsTab } from './StatsTab';
export { EquipmentTab } from './EquipmentTab';
export { AttunementsTab } from './AttunementsTab';
export { DebugTab } from './DebugTab';

66
src/components/ui/accordion.tsx Executable file
View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

66
src/components/ui/alert.tsx Executable file
View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,11 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }

53
src/components/ui/avatar.tsx Executable file
View File

@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

46
src/components/ui/badge.tsx Executable file
View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

109
src/components/ui/breadcrumb.tsx Executable file
View File

@@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

59
src/components/ui/button.tsx Executable file
View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

213
src/components/ui/calendar.tsx Executable file
View File

@@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

92
src/components/ui/card.tsx Executable file
View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

241
src/components/ui/carousel.tsx Executable file
View File

@@ -0,0 +1,241 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

353
src/components/ui/chart.tsx Executable file
View File

@@ -0,0 +1,353 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

32
src/components/ui/checkbox.tsx Executable file
View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

184
src/components/ui/command.tsx Executable file
View File

@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,252 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

143
src/components/ui/dialog.tsx Executable file
View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

135
src/components/ui/drawer.tsx Executable file
View File

@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

167
src/components/ui/form.tsx Executable file
View File

@@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

77
src/components/ui/input-otp.tsx Executable file
View File

@@ -0,0 +1,77 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

21
src/components/ui/input.tsx Executable file
View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

24
src/components/ui/label.tsx Executable file
View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

276
src/components/ui/menubar.tsx Executable file
View File

@@ -0,0 +1,276 @@
"use client"
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className
)}
{...props}
/>
)
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}

View File

@@ -0,0 +1,168 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}

127
src/components/ui/pagination.tsx Executable file
View File

@@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

48
src/components/ui/popover.tsx Executable file
View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

31
src/components/ui/progress.tsx Executable file
View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

56
src/components/ui/resizable.tsx Executable file
View File

@@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

185
src/components/ui/select.tsx Executable file
View File

@@ -0,0 +1,185 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

28
src/components/ui/separator.tsx Executable file
View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

139
src/components/ui/sheet.tsx Executable file
View File

@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

726
src/components/ui/sidebar.tsx Executable file
View File

@@ -0,0 +1,726 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

13
src/components/ui/skeleton.tsx Executable file
View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

63
src/components/ui/slider.tsx Executable file
View File

@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

25
src/components/ui/sonner.tsx Executable file
View File

@@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

31
src/components/ui/switch.tsx Executable file
View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

116
src/components/ui/table.tsx Executable file
View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

66
src/components/ui/tabs.tsx Executable file
View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

18
src/components/ui/textarea.tsx Executable file
View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

129
src/components/ui/toast.tsx Executable file
View File

@@ -0,0 +1,129 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

35
src/components/ui/toaster.tsx Executable file
View File

@@ -0,0 +1,35 @@
"use client"
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,73 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

47
src/components/ui/toggle.tsx Executable file
View File

@@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

61
src/components/ui/tooltip.tsx Executable file
View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

19
src/hooks/use-mobile.ts Executable file
View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

194
src/hooks/use-toast.ts Executable file
View File

@@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

13
src/lib/db.ts Executable file
View File

@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: ['query'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db

View File

@@ -0,0 +1,272 @@
import { describe, it, expect } from 'vitest';
import {
fmt,
fmtDec,
getFloorMaxHP,
getFloorElement,
canAffordSpellCost,
deductSpellCost,
computeMaxMana,
computeRegen,
computeClickMana,
getMeditationBonus,
getIncursionStrength,
} from '../computed-stats';
import { MAX_DAY, INCURSION_START_DAY, HOURS_PER_TICK } from '../constants';
describe('fmt', () => {
it('should format numbers < 1000 as integers', () => {
expect(fmt(500)).toBe('500');
expect(fmt(0)).toBe('0');
expect(fmt(999)).toBe('999');
});
it('should format thousands with K suffix', () => {
expect(fmt(1500)).toBe('1.5K');
expect(fmt(1000)).toBe('1.0K');
expect(fmt(9999)).toBe('10.0K');
});
it('should format millions with M suffix', () => {
expect(fmt(1500000)).toBe('1.50M');
expect(fmt(1000000)).toBe('1.00M');
});
it('should format billions with B suffix', () => {
expect(fmt(1500000000)).toBe('1.50B');
expect(fmt(1000000000)).toBe('1.00B');
});
it('should handle edge cases', () => {
expect(fmt(NaN)).toBe('0');
expect(fmt(Infinity)).toBe('0');
expect(fmt(-100)).toBe('-100');
});
});
describe('fmtDec', () => {
it('should format decimal numbers', () => {
expect(fmtDec(1.5, 1)).toBe('1.5');
expect(fmtDec(1.234, 2)).toBe('1.23');
expect(fmtDec(1000.5, 1)).toBe('1000.5');
});
});
describe('getFloorMaxHP', () => {
it('should return base HP for floor 1', () => {
const hp = getFloorMaxHP(1);
expect(hp).toBeGreaterThan(0);
});
it('should scale HP with floor number', () => {
const hp1 = getFloorMaxHP(1);
const hp5 = getFloorMaxHP(5);
const hp10 = getFloorMaxHP(10);
expect(hp5).toBeGreaterThan(hp1);
expect(hp10).toBeGreaterThan(hp5);
});
it('should return higher HP for guardian floors', () => {
// Floor 10 is Ignis Prime guardian
const hp10 = getFloorMaxHP(10);
const hp9 = getFloorMaxHP(9);
expect(hp10).toBeGreaterThan(hp9 * 2); // Guardians have much more HP
});
});
describe('getFloorElement', () => {
it('should return an element string for valid floors', () => {
const elem = getFloorElement(1);
expect(typeof elem).toBe('string');
expect(['fire', 'water', 'earth', 'air', 'raw']).toContain(elem);
});
it('should cycle through elements based on floor number', () => {
// Check that floors have different elements
const elem1 = getFloorElement(1);
const elem7 = getFloorElement(7);
// Since it cycles every 5 floors, floor 1 and 7 might have same element
const elem2 = getFloorElement(2);
// Floor 1 and 2 should have different elements (if cycle allows)
});
});
describe('canAffordSpellCost', () => {
it('should return true for raw mana cost when enough mana', () => {
const result = canAffordSpellCost({ type: 'raw', amount: 10 }, 50, {});
expect(result).toBe(true);
});
it('should return false for raw mana cost when not enough mana', () => {
const result = canAffordSpellCost({ type: 'raw', amount: 100 }, 50, {});
expect(result).toBe(false);
});
it('should handle zero cost', () => {
const result = canAffordSpellCost({ type: 'raw', amount: 0 }, 0, {});
expect(result).toBe(true);
});
it('should handle elemental costs', () => {
const elements = {
fire: { current: 10, max: 50, unlocked: true },
};
const result = canAffordSpellCost({ type: 'element', element: 'fire', amount: 5 }, 0, elements);
expect(result).toBe(true);
});
it('should return false for elemental cost when not enough', () => {
const elements = {
fire: { current: 3, max: 50, unlocked: true },
};
const result = canAffordSpellCost({ type: 'element', element: 'fire', amount: 5 }, 0, elements);
expect(result).toBe(false);
});
it('should return false for locked element', () => {
const elements = {
fire: { current: 10, max: 50, unlocked: false },
};
const result = canAffordSpellCost({ type: 'element', element: 'fire', amount: 5 }, 0, elements);
expect(result).toBe(false);
});
});
describe('deductSpellCost', () => {
it('should deduct raw mana correctly', () => {
const result = deductSpellCost({ type: 'raw', amount: 10 }, 50, {});
expect(result.rawMana).toBe(40);
});
it('should not go below zero', () => {
const result = deductSpellCost({ type: 'raw', amount: 100 }, 50, {});
expect(result.rawMana).toBe(0);
});
it('should deduct elemental mana correctly', () => {
const elements = {
fire: { current: 10, max: 50, unlocked: true },
};
const result = deductSpellCost({ type: 'element', element: 'fire', amount: 5 }, 0, elements);
expect(result.elements.fire.current).toBe(5);
});
it('should return same values when cost is zero', () => {
const elements = {
fire: { current: 10, max: 50, unlocked: true },
};
const result = deductSpellCost({ type: 'raw', amount: 0 }, 50, elements);
expect(result.rawMana).toBe(50);
expect(result.elements.fire.current).toBe(10);
});
});
describe('computeMaxMana', () => {
it('should return base 100 with no skills or upgrades', () => {
const state = {
skills: {},
prestigeUpgrades: {},
skillUpgrades: {},
skillTiers: {},
};
const effects = { maxManaBonus: 0, maxManaMultiplier: 1 };
const result = computeMaxMana(state, effects);
expect(result).toBe(100);
});
it('should include manaWell prestige upgrade', () => {
const state = {
skills: {},
prestigeUpgrades: { manaWell: 5 },
skillUpgrades: {},
skillTiers: {},
};
const effects = { maxManaBonus: 0, maxManaMultiplier: 1 };
const result = computeMaxMana(state, effects);
expect(result).toBe(100 + 5 * 500); // Base + 500 per level
});
it('should apply multiplier from effects', () => {
const state = {
skills: {},
prestigeUpgrades: {},
skillUpgrades: {},
skillTiers: {},
};
const effects = { maxManaBonus: 0, maxManaMultiplier: 1.5 };
const result = computeMaxMana(state, effects);
expect(result).toBe(150); // 100 * 1.5
});
it('should apply bonus from effects', () => {
const state = {
skills: {},
prestigeUpgrades: {},
skillUpgrades: {},
skillTiers: {},
};
const effects = { maxManaBonus: 50, maxManaMultiplier: 1 };
const result = computeMaxMana(state, effects);
expect(result).toBe(150); // 100 + 50
});
});
describe('computeRegen', () => {
it('should return base regen with no skills', () => {
const state = {
skills: {},
prestigeUpgrades: {},
skillUpgrades: {},
skillTiers: {},
};
const effects = { regenBonus: 0, regenMultiplier: 1, permanentRegenBonus: 0 };
const result = computeRegen(state, effects);
expect(result).toBe(2); // Base regen
});
});
describe('computeClickMana', () => {
it('should return base click mana with no skills', () => {
const state = {
skills: {},
prestigeUpgrades: {},
skillUpgrades: {},
skillTiers: {},
};
const effects = { clickManaBonus: 0, clickManaMultiplier: 1 };
const result = computeClickMana(state, effects);
expect(result).toBeGreaterThanOrEqual(1);
});
});
describe('getMeditationBonus', () => {
it('should return 1.0 with zero ticks', () => {
const result = getMeditationBonus(0, {});
expect(result).toBe(1.0);
});
it('should increase with more ticks', () => {
const result1 = getMeditationBonus(10, {});
const result2 = getMeditationBonus(100, {});
expect(result2).toBeGreaterThan(result1);
});
});
describe('getIncursionStrength', () => {
it('should return 0 before incursion start day', () => {
const result = getIncursionStrength(1, 12);
expect(result).toBe(0);
});
it('should return positive value during incursion', () => {
// After incursion start day
const result = getIncursionStrength(INCURSION_START_DAY, 12);
expect(result).toBeGreaterThan(0);
});
it('should increase with later days', () => {
const result1 = getIncursionStrength(INCURSION_START_DAY, 12);
const result2 = getIncursionStrength(MAX_DAY, 12);
expect(result2).toBeGreaterThan(result1);
});
});

567
src/lib/game/attunements.ts Executable file
View File

@@ -0,0 +1,567 @@
// ─── Attunement System ─────────────────────────────────────────────────────────
// Attunements are powerful magical bonds tied to specific body locations
// Each grants a unique capability, primary mana type, and skill tree
import type { SkillDef } from './types';
// ─── Body Slots ───────────────────────────────────────────────────────────────
export type AttunementSlot =
| 'rightHand'
| 'leftHand'
| 'head'
| 'back'
| 'chest'
| 'leftLeg'
| 'rightLeg';
export const ATTUNEMENT_SLOTS: AttunementSlot[] = [
'rightHand',
'leftHand',
'head',
'back',
'chest',
'leftLeg',
'rightLeg',
];
// Slot display names
export const ATTUNEMENT_SLOT_NAMES: Record<AttunementSlot, string> = {
rightHand: 'Right Hand',
leftHand: 'Left Hand',
head: 'Head',
back: 'Back',
chest: 'Heart',
leftLeg: 'Left Leg',
rightLeg: 'Right Leg',
};
// ─── Mana Types ───────────────────────────────────────────────────────────────
export type ManaType =
// Primary mana types from attunements
| 'transference' // Enchanter - moving/enchanting
| 'form' // Caster - shaping spells
| 'vision' // Seer - perception/revelation
| 'barrier' // Warden - protection/defense
| 'flow' // Strider - movement/swiftness
| 'stability' // Anchor - grounding/endurance
// Guardian pact types (Invoker)
| 'fire'
| 'water'
| 'earth'
| 'air'
| 'light'
| 'dark'
| 'life'
| 'death'
// Raw mana
| 'raw';
// ─── Attunement Types ─────────────────────────────────────────────────────────
export type AttunementType =
| 'enchanter'
| 'caster'
| 'seer'
| 'warden'
| 'invoker'
| 'strider'
| 'anchor';
// ─── Attunement Definition ────────────────────────────────────────────────────
export interface AttunementDef {
id: AttunementType;
name: string;
slot: AttunementSlot;
description: string;
capability: string; // What this attunement unlocks
primaryManaType: ManaType | null; // null for Invoker (uses guardian types)
rawManaRegen: number; // Base raw mana regen bonus
autoConvertRate: number; // Raw mana -> primary mana per hour
skills: Record<string, SkillDef>; // Attunement-specific skills
icon: string; // Lucide icon name
color: string; // Theme color
}
// ─── Attunement State ─────────────────────────────────────────────────────────
export interface AttunementState {
unlocked: boolean;
level: number; // Attunement level (from challenges)
manaPool: number; // Current primary mana
maxMana: number; // Max primary mana pool
}
// ─── Attunement Definitions ───────────────────────────────────────────────────
export const ATTUNEMENTS: Record<AttunementType, AttunementDef> = {
// ═══════════════════════════════════════════════════════════════════════════
// ENCHANTER - Right Hand
// The starting attunement. Grants access to enchanting and transference magic.
// ═══════════════════════════════════════════════════════════════════════════
enchanter: {
id: 'enchanter',
name: 'Enchanter',
slot: 'rightHand',
description: 'Channel mana through your right hand to imbue equipment with magical properties.',
capability: 'Unlock enchanting. Apply enchantments using transference mana.',
primaryManaType: 'transference',
rawManaRegen: 0.5,
autoConvertRate: 0.2, // 0.2 transference per hour per raw regen
icon: 'Wand2',
color: '#8B5CF6', // Purple
skills: {
// Core enchanting skills
enchanting: {
name: 'Enchanting',
desc: 'Apply magical effects to equipment',
cat: 'enchanter',
max: 10,
base: 100,
studyTime: 8,
},
efficientEnchant: {
name: 'Efficient Enchanting',
desc: 'Reduce enchantment mana costs',
cat: 'enchanter',
max: 5,
base: 200,
studyTime: 12,
req: { enchanting: 3 },
},
disenchanting: {
name: 'Disenchanting',
desc: 'Remove enchantments and recover some mana',
cat: 'enchanter',
max: 5,
base: 150,
studyTime: 10,
req: { enchanting: 2 },
},
enchantSpeed: {
name: 'Swift Enchanting',
desc: 'Faster enchantment application',
cat: 'enchanter',
max: 5,
base: 175,
studyTime: 10,
req: { enchanting: 2 },
},
transferenceMastery: {
name: 'Transference Mastery',
desc: 'Increased transference mana pool and regen',
cat: 'enchanter',
max: 10,
base: 250,
studyTime: 15,
},
},
},
// ═══════════════════════════════════════════════════════════════════════════
// CASTER - Left Hand
// Shapes raw mana into spell patterns. Enhanced spell damage.
// ═══════════════════════════════════════════════════════════════════════════
caster: {
id: 'caster',
name: 'Caster',
slot: 'leftHand',
description: 'Shape mana into devastating spell patterns through your left hand.',
capability: 'Form mana shaping. +25% spell damage bonus.',
primaryManaType: 'form',
rawManaRegen: 0.3,
autoConvertRate: 0.15,
icon: 'Hand',
color: '#3B82F6', // Blue
skills: {
spellShaping: {
name: 'Spell Shaping',
desc: 'Increase spell damage and efficiency',
cat: 'caster',
max: 10,
base: 100,
studyTime: 8,
},
quickCast: {
name: 'Quick Cast',
desc: 'Faster spell casting speed',
cat: 'caster',
max: 10,
base: 120,
studyTime: 8,
},
spellEcho: {
name: 'Spell Echo',
desc: 'Chance to cast spells twice',
cat: 'caster',
max: 5,
base: 300,
studyTime: 15,
req: { spellShaping: 5 },
},
formMastery: {
name: 'Form Mastery',
desc: 'Increased form mana pool and regen',
cat: 'caster',
max: 10,
base: 250,
studyTime: 15,
},
},
},
// ═══════════════════════════════════════════════════════════════════════════
// SEER - Head
// Perception and revelation. Critical hit bonus and weakness detection.
// ═══════════════════════════════════════════════════════════════════════════
seer: {
id: 'seer',
name: 'Seer',
slot: 'head',
description: 'See beyond the veil. Reveal hidden truths and enemy weaknesses.',
capability: 'Reveal floor weaknesses. +20% critical hit chance.',
primaryManaType: 'vision',
rawManaRegen: 0.2,
autoConvertRate: 0.1,
icon: 'Eye',
color: '#F59E0B', // Amber
skills: {
insight: {
name: 'Insight',
desc: 'Increased critical hit chance',
cat: 'seer',
max: 10,
base: 100,
studyTime: 8,
},
revealWeakness: {
name: 'Reveal Weakness',
desc: 'Show enemy elemental weaknesses',
cat: 'seer',
max: 5,
base: 200,
studyTime: 12,
},
foresight: {
name: 'Foresight',
desc: 'Chance to anticipate and dodge attacks',
cat: 'seer',
max: 5,
base: 250,
studyTime: 15,
req: { insight: 5 },
},
visionMastery: {
name: 'Vision Mastery',
desc: 'Increased vision mana pool and regen',
cat: 'seer',
max: 10,
base: 250,
studyTime: 15,
},
},
},
// ═══════════════════════════════════════════════════════════════════════════
// WARDEN - Back
// Protection and defense. Damage reduction and shields.
// ═══════════════════════════════════════════════════════════════════════════
warden: {
id: 'warden',
name: 'Warden',
slot: 'back',
description: 'Shield yourself with protective wards and barriers.',
capability: 'Generate protective shields. -10% damage taken.',
primaryManaType: 'barrier',
rawManaRegen: 0.25,
autoConvertRate: 0.12,
icon: 'Shield',
color: '#10B981', // Green
skills: {
warding: {
name: 'Warding',
desc: 'Generate protective shields',
cat: 'warden',
max: 10,
base: 100,
studyTime: 8,
},
fortitude: {
name: 'Fortitude',
desc: 'Reduce damage taken',
cat: 'warden',
max: 10,
base: 150,
studyTime: 10,
},
reflection: {
name: 'Reflection',
desc: 'Chance to reflect damage to attacker',
cat: 'warden',
max: 5,
base: 300,
studyTime: 15,
req: { warding: 5 },
},
barrierMastery: {
name: 'Barrier Mastery',
desc: 'Increased barrier mana pool and regen',
cat: 'warden',
max: 10,
base: 250,
studyTime: 15,
},
},
},
// ═══════════════════════════════════════════════════════════════════════════
// INVOKER - Chest/Heart
// Pact with guardians. No primary mana - uses guardian elemental types.
// ═══════════════════════════════════════════════════════════════════════════
invoker: {
id: 'invoker',
name: 'Invoker',
slot: 'chest',
description: 'Form pacts with spire guardians and channel their elemental power.',
capability: 'Pact with guardians. Gain mana types from pacted guardians.',
primaryManaType: null, // Uses guardian types instead
rawManaRegen: 0.4,
autoConvertRate: 0, // No auto-convert; mana comes from guardian pacts
icon: 'Heart',
color: '#EF4444', // Red
skills: {
pactMaking: {
name: 'Pact Making',
desc: 'Form stronger pacts with guardians',
cat: 'invoker',
max: 10,
base: 100,
studyTime: 8,
},
guardianChannel: {
name: 'Guardian Channeling',
desc: 'Channel guardian powers more effectively',
cat: 'invoker',
max: 10,
base: 150,
studyTime: 10,
},
elementalBurst: {
name: 'Elemental Burst',
desc: 'Unleash stored guardian energy',
cat: 'invoker',
max: 5,
base: 300,
studyTime: 15,
req: { pactMaking: 5, guardianChannel: 3 },
},
soulResonance: {
name: 'Soul Resonance',
desc: 'Deep bond with pacted guardians',
cat: 'invoker',
max: 5,
base: 400,
studyTime: 20,
req: { pactMaking: 8 },
},
},
},
// ═══════════════════════════════════════════════════════════════════════════
// STRIDER - Left Leg
// Movement and swiftness. Attack speed and mobility.
// ═══════════════════════════════════════════════════════════════════════════
strider: {
id: 'strider',
name: 'Strider',
slot: 'leftLeg',
description: 'Move with supernatural speed and grace.',
capability: 'Enhanced mobility. +15% attack speed.',
primaryManaType: 'flow',
rawManaRegen: 0.3,
autoConvertRate: 0.15,
icon: 'Zap',
color: '#06B6D4', // Cyan
skills: {
swiftness: {
name: 'Swiftness',
desc: 'Increased attack and movement speed',
cat: 'strider',
max: 10,
base: 100,
studyTime: 8,
},
evasive: {
name: 'Evasive',
desc: 'Chance to avoid damage',
cat: 'strider',
max: 5,
base: 200,
studyTime: 12,
},
momentum: {
name: 'Momentum',
desc: 'Build speed over consecutive attacks',
cat: 'strider',
max: 5,
base: 250,
studyTime: 15,
req: { swiftness: 5 },
},
flowMastery: {
name: 'Flow Mastery',
desc: 'Increased flow mana pool and regen',
cat: 'strider',
max: 10,
base: 250,
studyTime: 15,
},
},
},
// ═══════════════════════════════════════════════════════════════════════════
// ANCHOR - Right Leg
// Stability and endurance. Max mana and knockback resistance.
// ═══════════════════════════════════════════════════════════════════════════
anchor: {
id: 'anchor',
name: 'Anchor',
slot: 'rightLeg',
description: 'Stand firm against any force. Your foundation is unshakeable.',
capability: 'Increased stability. +100 max mana.',
primaryManaType: 'stability',
rawManaRegen: 0.35,
autoConvertRate: 0.18,
icon: 'Mountain',
color: '#78716C', // Stone gray
skills: {
grounding: {
name: 'Grounding',
desc: 'Increased max mana and stability',
cat: 'anchor',
max: 10,
base: 100,
studyTime: 8,
},
endurance: {
name: 'Endurance',
desc: 'Reduced mana costs when below 50% mana',
cat: 'anchor',
max: 5,
base: 200,
studyTime: 12,
},
ironWill: {
name: 'Iron Will',
desc: 'Prevent mana drain effects',
cat: 'anchor',
max: 5,
base: 250,
studyTime: 15,
req: { grounding: 5 },
},
stabilityMastery: {
name: 'Stability Mastery',
desc: 'Increased stability mana pool and regen',
cat: 'anchor',
max: 10,
base: 250,
studyTime: 15,
},
},
},
};
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Get the attunement for a specific body slot
*/
export function getAttunementForSlot(slot: AttunementSlot): AttunementDef | undefined {
return Object.values(ATTUNEMENTS).find(a => a.slot === slot);
}
/**
* Get the starting attunement (Enchanter - right hand)
*/
export function getStartingAttunement(): AttunementDef {
return ATTUNEMENTS.enchanter;
}
/**
* Check if an attunement is unlocked for the player
*/
export function isAttunementUnlocked(
attunementStates: Record<AttunementType, AttunementState>,
attunementType: AttunementType
): boolean {
return attunementStates[attunementType]?.unlocked ?? false;
}
/**
* Get total raw mana regen from all unlocked attunements
*/
export function getTotalAttunementRegen(
attunementStates: Record<AttunementType, AttunementState>
): number {
let total = 0;
for (const [type, state] of Object.entries(attunementStates)) {
if (state.unlocked) {
const def = ATTUNEMENTS[type as AttunementType];
if (def) {
total += def.rawManaRegen * (1 + state.level * 0.1); // +10% per level
}
}
}
return total;
}
/**
* Get mana type display name
*/
export function getManaTypeName(type: ManaType): string {
const names: Record<ManaType, string> = {
raw: 'Raw Mana',
transference: 'Transference',
form: 'Form',
vision: 'Vision',
barrier: 'Barrier',
flow: 'Flow',
stability: 'Stability',
fire: 'Fire',
water: 'Water',
earth: 'Earth',
air: 'Air',
light: 'Light',
dark: 'Dark',
life: 'Life',
death: 'Death',
};
return names[type] || type;
}
/**
* Get mana type color
*/
export function getManaTypeColor(type: ManaType): string {
const colors: Record<ManaType, string> = {
raw: '#A78BFA', // Light purple
transference: '#8B5CF6', // Purple
form: '#3B82F6', // Blue
vision: '#F59E0B', // Amber
barrier: '#10B981', // Green
flow: '#06B6D4', // Cyan
stability: '#78716C', // Stone
fire: '#EF4444', // Red
water: '#3B82F6', // Blue
earth: '#A16207', // Brown
air: '#94A3B8', // Slate
light: '#FCD34D', // Yellow
dark: '#6B7280', // Gray
life: '#22C55E', // Green
death: '#7C3AED', // Violet
};
return colors[type] || '#A78BFA';
}

492
src/lib/game/computed-stats.ts Executable file
View File

@@ -0,0 +1,492 @@
// ─── Computed Stats and Utility Functions ───────────────────────────────────────
// This module contains all computed stat functions and utility helpers
// extracted from the main store for better organization
import type { GameState, SpellCost, EquipmentInstance } from './types';
import {
GUARDIANS,
SPELLS_DEF,
FLOOR_ELEM_CYCLE,
HOURS_PER_TICK,
MAX_DAY,
INCURSION_START_DAY,
ELEMENT_OPPOSITES,
ELEMENTS,
TICK_MS,
} from './constants';
import type { ComputedEffects } from './upgrade-effects';
import { getUnifiedEffects, type UnifiedEffects } from './effects';
import { EQUIPMENT_TYPES } from './data/equipment';
import { ENCHANTMENT_EFFECTS } from './data/enchantment-effects';
// ─── Default Effects Constant ───────────────────────────────────────────────────
// Default empty effects for when effects aren't provided
export const DEFAULT_EFFECTS: ComputedEffects = {
maxManaMultiplier: 1,
maxManaBonus: 0,
regenMultiplier: 1,
regenBonus: 0,
clickManaMultiplier: 1,
clickManaBonus: 0,
meditationEfficiency: 1,
spellCostMultiplier: 1,
conversionEfficiency: 1,
baseDamageMultiplier: 1,
baseDamageBonus: 0,
attackSpeedMultiplier: 1,
critChanceBonus: 0,
critDamageMultiplier: 1.5,
elementalDamageMultiplier: 1,
studySpeedMultiplier: 1,
studyCostMultiplier: 1,
progressRetention: 0,
instantStudyChance: 0,
freeStudyChance: 0,
elementCapMultiplier: 1,
elementCapBonus: 0,
conversionCostMultiplier: 1,
doubleCraftChance: 0,
permanentRegenBonus: 0,
specials: new Set(),
activeUpgrades: [],
};
// ─── Number Formatting Functions ────────────────────────────────────────────────
export function fmt(n: number): string {
if (!isFinite(n) || isNaN(n)) return '0';
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
return Math.floor(n).toString();
}
export function fmtDec(n: number, d: number = 1): string {
return isFinite(n) ? n.toFixed(d) : '0';
}
// ─── Floor Functions ────────────────────────────────────────────────────────────
export function getFloorMaxHP(floor: number): number {
if (GUARDIANS[floor]) return GUARDIANS[floor].hp;
// Improved scaling: slower early game, faster late game
const baseHP = 100;
const floorScaling = floor * 50;
const exponentialScaling = Math.pow(floor, 1.7);
return Math.floor(baseHP + floorScaling + exponentialScaling);
}
export function getFloorElement(floor: number): string {
return FLOOR_ELEM_CYCLE[(floor - 1) % 8];
}
// ─── Equipment Spell Helper ─────────────────────────────────────────────────────
// Get all spells from equipped caster weapons (staves, wands, etc.)
// Returns array of { spellId, equipmentInstanceId }
export function getActiveEquipmentSpells(
equippedInstances: Record<string, string | null>,
equipmentInstances: Record<string, EquipmentInstance>
): Array<{ spellId: string; equipmentId: string }> {
const spells: Array<{ spellId: string; equipmentId: string }> = [];
// Check main hand and off hand for caster equipment
const weaponSlots = ['mainHand', 'offHand'] as const;
for (const slot of weaponSlots) {
const instanceId = equippedInstances[slot];
if (!instanceId) continue;
const instance = equipmentInstances[instanceId];
if (!instance) continue;
// Check if this is a caster-type equipment
const equipType = EQUIPMENT_TYPES[instance.typeId];
if (!equipType || equipType.category !== 'caster') continue;
// Get spells from enchantments
for (const ench of instance.enchantments) {
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) {
spells.push({
spellId: effectDef.effect.spellId,
equipmentId: instanceId,
});
}
}
}
return spells;
}
// ─── Skill Level Helper ─────────────────────────────────────────────────────────
// Helper to get effective skill level accounting for tiers
export function getEffectiveSkillLevel(
skills: Record<string, number>,
baseSkillId: string,
skillTiers: Record<string, number> = {}
): { level: number; tier: number; tierMultiplier: number } {
// Find the highest tier the player has for this base skill
const currentTier = skillTiers[baseSkillId] || 1;
// Look for the tiered skill ID (e.g., manaFlow_t2)
const tieredSkillId = currentTier > 1 ? `${baseSkillId}_t${currentTier}` : baseSkillId;
const level = skills[tieredSkillId] || skills[baseSkillId] || 0;
// Tier multiplier: each tier is 10x more powerful
const tierMultiplier = Math.pow(10, currentTier - 1);
return { level, tier: currentTier, tierMultiplier };
}
// ─── Computed Stat Functions ────────────────────────────────────────────────────
export function computeMaxMana(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: ComputedEffects | UnifiedEffects
): number {
const pu = state.prestigeUpgrades;
const base =
100 +
(state.skills.manaWell || 0) * 100 +
(pu.manaWell || 0) * 500;
// If effects not provided, compute unified effects (includes equipment)
if (!effects && state.equipmentInstances && state.equippedInstances) {
effects = getUnifiedEffects(state as any);
}
// Apply effects if available (now includes equipment bonuses)
if (effects) {
return Math.floor((base + effects.maxManaBonus) * effects.maxManaMultiplier);
}
return base;
}
export function computeElementMax(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
effects?: ComputedEffects
): number {
const pu = state.prestigeUpgrades;
const base = 10 + (state.skills.elemAttune || 0) * 50 + (pu.elementalAttune || 0) * 25;
// Apply upgrade effects if provided
if (effects) {
return Math.floor((base + effects.elementCapBonus) * effects.elementCapMultiplier);
}
return base;
}
export function computeRegen(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: ComputedEffects | UnifiedEffects
): number {
const pu = state.prestigeUpgrades;
const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1;
const base =
2 +
(state.skills.manaFlow || 0) * 1 +
(state.skills.manaSpring || 0) * 2 +
(pu.manaFlow || 0) * 0.5;
let regen = base * temporalBonus;
// If effects not provided, compute unified effects (includes equipment)
if (!effects && state.equipmentInstances && state.equippedInstances) {
effects = getUnifiedEffects(state as any);
}
// Apply effects if available (now includes equipment bonuses)
if (effects) {
regen = (regen + effects.regenBonus + effects.permanentRegenBonus) * effects.regenMultiplier;
}
return regen;
}
/**
* Compute regen with dynamic special effects (needs current mana, max mana, incursion)
*/
export function computeEffectiveRegen(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'rawMana' | 'incursionStrength' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: ComputedEffects | UnifiedEffects
): number {
// Base regen from existing function
let regen = computeRegen(state, effects);
const maxMana = computeMaxMana(state, effects);
const currentMana = state.rawMana;
const incursionStrength = state.incursionStrength || 0;
// Apply incursion penalty
regen *= (1 - incursionStrength);
return regen;
}
export function computeClickMana(
state: Pick<GameState, 'skills' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: ComputedEffects | UnifiedEffects
): number {
const base =
1 +
(state.skills.manaTap || 0) * 1 +
(state.skills.manaSurge || 0) * 3;
// If effects not provided, compute unified effects (includes equipment)
if (!effects && state.equipmentInstances && state.equippedInstances) {
effects = getUnifiedEffects(state as any);
}
// Apply effects if available (now includes equipment bonuses)
if (effects) {
return Math.floor((base + effects.clickManaBonus) * effects.clickManaMultiplier);
}
return base;
}
// ─── Damage Calculation Helpers ─────────────────────────────────────────────────
// Elemental damage bonus: +50% if spell element opposes floor element (super effective)
// -25% if spell element matches its own opposite (weak)
export function getElementalBonus(spellElem: string, floorElem: string): number {
if (spellElem === 'raw') return 1.0; // Raw mana has no elemental bonus
if (spellElem === floorElem) return 1.25; // Same element: +25% damage
// Check for super effective first: spell is the opposite of floor
// e.g., casting water (opposite of fire) at fire floor = super effective
if (ELEMENT_OPPOSITES[floorElem] === spellElem) return 1.5; // Super effective: +50% damage
// Check for weak: spell's opposite matches floor
// e.g., casting fire (whose opposite is water) at water floor = weak
if (ELEMENT_OPPOSITES[spellElem] === floorElem) return 0.75; // Weak: -25% damage
return 1.0; // Neutral
}
export function calcDamage(
state: Pick<GameState, 'skills' | 'signedPacts'>,
spellId: string,
floorElem?: string
): number {
const sp = SPELLS_DEF[spellId];
if (!sp) return 5;
const skills = state.skills;
const baseDmg = sp.dmg + (skills.combatTrain || 0) * 5;
const pct = 1 + (skills.arcaneFury || 0) * 0.1;
// Elemental mastery bonus
const elemMasteryBonus = 1 + (skills.elementalMastery || 0) * 0.15;
// Guardian bane bonus
const guardianBonus = floorElem && GUARDIANS[Object.values(GUARDIANS).find(g => g.element === floorElem)?.hp ? 0 : 0]
? 1 + (skills.guardianBane || 0) * 0.2
: 1;
const critChance = (skills.precision || 0) * 0.05;
const pactMult = state.signedPacts.reduce(
(m, f) => m * (GUARDIANS[f]?.pact || 1),
1
);
let damage = baseDmg * pct * pactMult * elemMasteryBonus;
// Apply elemental bonus if floor element provided
if (floorElem) {
damage *= getElementalBonus(sp.elem, floorElem);
}
// Apply crit
if (Math.random() < critChance) {
damage *= 1.5;
}
return damage;
}
// ─── Insight Calculation ────────────────────────────────────────────────────────
export function calcInsight(state: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'prestigeUpgrades' | 'skills'>): number {
const pu = state.prestigeUpgrades;
const skillBonus = 1 + (state.skills.insightHarvest || 0) * 0.1;
const mult = (1 + (pu.insightAmp || 0) * 0.25) * skillBonus;
return Math.floor(
(state.maxFloorReached * 15 + state.totalManaGathered / 500 + state.signedPacts.length * 150) * mult
);
}
// ─── Meditation Bonus ───────────────────────────────────────────────────────────
// Meditation bonus now affects regen rate directly
export function getMeditationBonus(meditateTicks: number, skills: Record<string, number>, meditationEfficiency: number = 1): number {
const hasMeditation = skills.meditation === 1;
const hasDeepTrance = skills.deepTrance === 1;
const hasVoidMeditation = skills.voidMeditation === 1;
const hours = meditateTicks * HOURS_PER_TICK;
// Base meditation: ramps up over 4 hours to 1.5x
let bonus = 1 + Math.min(hours / 4, 0.5);
// With Meditation Focus: up to 2.5x after 4 hours
if (hasMeditation && hours >= 4) {
bonus = 2.5;
}
// With Deep Trance: up to 3.0x after 6 hours
if (hasDeepTrance && hours >= 6) {
bonus = 3.0;
}
// With Void Meditation: up to 5.0x after 8 hours
if (hasVoidMeditation && hours >= 8) {
bonus = 5.0;
}
// Apply meditation efficiency from upgrades (Deep Wellspring, etc.)
bonus *= meditationEfficiency;
return bonus;
}
// ─── Incursion Strength ─────────────────────────────────────────────────────────
export function getIncursionStrength(day: number, hour: number): number {
if (day < INCURSION_START_DAY) return 0;
const totalHours = (day - INCURSION_START_DAY) * 24 + hour;
const maxHours = (MAX_DAY - INCURSION_START_DAY) * 24;
return Math.min(0.95, (totalHours / maxHours) * 0.95);
}
// ─── Spell Cost Helpers ─────────────────────────────────────────────────────────
// Check if player can afford spell cost
export function canAffordSpellCost(
cost: SpellCost,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>
): boolean {
if (cost.type === 'raw') {
return rawMana >= cost.amount;
} else {
const elem = elements[cost.element || ''];
return elem && elem.unlocked && elem.current >= cost.amount;
}
}
// Deduct spell cost from appropriate mana pool
export function deductSpellCost(
cost: SpellCost,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
const newElements = { ...elements };
if (cost.type === 'raw') {
// Clamp to 0 to prevent negative mana
return { rawMana: Math.max(0, rawMana - cost.amount), elements: newElements };
} else if (cost.element && newElements[cost.element]) {
newElements[cost.element] = {
...newElements[cost.element],
current: Math.max(0, newElements[cost.element].current - cost.amount)
};
return { rawMana, elements: newElements };
}
return { rawMana, elements: newElements };
}
// ─── Damage Breakdown Helper ───────────────────────────────────────────────────
export interface DamageBreakdown {
base: number;
combatTrainBonus: number;
arcaneFuryMult: number;
elemMasteryMult: number;
guardianBaneMult: number;
pactMult: number;
precisionChance: number;
elemBonus: number;
elemBonusText: string;
total: number;
}
export function getDamageBreakdown(
state: Pick<GameState, 'skills' | 'signedPacts'>,
activeSpellId: string,
floorElem: string,
isGuardianFloor: boolean
): DamageBreakdown | null {
const spell = SPELLS_DEF[activeSpellId];
if (!spell) return null;
const baseDmg = spell.dmg;
const combatTrainBonus = (state.skills.combatTrain || 0) * 5;
const arcaneFuryMult = 1 + (state.skills.arcaneFury || 0) * 0.1;
const elemMasteryMult = 1 + (state.skills.elementalMastery || 0) * 0.15;
const guardianBaneMult = isGuardianFloor ? (1 + (state.skills.guardianBane || 0) * 0.2) : 1;
const pactMult = state.signedPacts.reduce((m, f) => m * (GUARDIANS[f]?.pact || 1), 1);
const precisionChance = (state.skills.precision || 0) * 0.05;
// Elemental bonus
let elemBonus = 1.0;
let elemBonusText = '';
if (spell.elem !== 'raw' && floorElem) {
if (spell.elem === floorElem) {
elemBonus = 1.25;
elemBonusText = '+25% same element';
} else if (ELEMENTS[spell.elem] && ELEMENT_OPPOSITES[floorElem] === spell.elem) {
elemBonus = 1.5;
elemBonusText = '+50% super effective';
}
}
return {
base: baseDmg,
combatTrainBonus,
arcaneFuryMult,
elemMasteryMult,
guardianBaneMult,
pactMult,
precisionChance,
elemBonus,
elemBonusText,
total: calcDamage(state, activeSpellId, floorElem)
};
}
// ─── Total DPS Calculation ─────────────────────────────────────────────────────
export function getTotalDPS(
state: Pick<GameState, 'skills' | 'signedPacts' | 'equippedInstances' | 'equipmentInstances'>,
upgradeEffects: { attackSpeedMultiplier: number },
floorElem: string
): number {
const quickCastBonus = 1 + (state.skills.quickCast || 0) * 0.05;
const attackSpeedMult = upgradeEffects.attackSpeedMultiplier;
const castsPerSecondMult = HOURS_PER_TICK / (TICK_MS / 1000);
const activeEquipmentSpells = getActiveEquipmentSpells(
state.equippedInstances,
state.equipmentInstances
);
let totalDPS = 0;
for (const { spellId } of activeEquipmentSpells) {
const spell = SPELLS_DEF[spellId];
if (!spell) continue;
const spellCastSpeed = spell.castSpeed || 1;
const totalCastSpeed = spellCastSpeed * quickCastBonus * attackSpeedMult;
const damagePerCast = calcDamage(state, spellId, floorElem);
const castsPerSecond = totalCastSpeed * castsPerSecondMult;
totalDPS += damagePerCast * castsPerSecond;
}
return totalDPS;
}

876
src/lib/game/constants.ts Executable file
View File

@@ -0,0 +1,876 @@
// ─── Game Constants ───────────────────────────────────────────────────────────
import type { ElementDef, GuardianDef, SpellDef, SkillDef, PrestigeDef, SpellCost } from './types';
// Time constants
export const TICK_MS = 200;
export const HOURS_PER_TICK = 0.04; // 200ms / 5000ms per in-game hour
export const MAX_DAY = 30;
export const INCURSION_START_DAY = 20;
// Mana constants
export const MANA_PER_ELEMENT = 100;
// Helper function for creating raw mana cost
export function rawCost(amount: number): SpellCost {
return { type: 'raw', amount };
}
// Helper function for creating elemental mana cost
export function elemCost(element: string, amount: number): SpellCost {
return { type: 'element', element, amount };
}
// ─── Element Definitions ─────────────────────────────────────────────────────
export const ELEMENTS: Record<string, ElementDef> = {
// Base Elements
fire: { name: "Fire", sym: "🔥", color: "#FF6B35", glow: "#FF6B3540", cat: "base" },
water: { name: "Water", sym: "💧", color: "#4ECDC4", glow: "#4ECDC440", cat: "base" },
air: { name: "Air", sym: "🌬️", color: "#00D4FF", glow: "#00D4FF40", cat: "base" },
earth: { name: "Earth", sym: "⛰️", color: "#F4A261", glow: "#F4A26140", cat: "base" },
light: { name: "Light", sym: "☀️", color: "#FFD700", glow: "#FFD70040", cat: "base" },
dark: { name: "Dark", sym: "🌑", color: "#9B59B6", glow: "#9B59B640", cat: "base" },
life: { name: "Life", sym: "🌿", color: "#2ECC71", glow: "#2ECC7140", cat: "base" },
death: { name: "Death", sym: "💀", color: "#778CA3", glow: "#778CA340", cat: "base" },
// Utility Elements
mental: { name: "Mental", sym: "🧠", color: "#E91E8C", glow: "#E91E8C40", cat: "utility" },
transference: { name: "Transference", sym: "🔗", color: "#1ABC9C", glow: "#1ABC9C40", cat: "utility" },
force: { name: "Force", sym: "💨", color: "#E74C3C", glow: "#E74C3C40", cat: "utility" },
// Composite Elements
blood: { name: "Blood", sym: "🩸", color: "#C0392B", glow: "#C0392B40", cat: "composite", recipe: ["life", "water"] },
metal: { name: "Metal", sym: "⚙️", color: "#BDC3C7", glow: "#BDC3C740", cat: "composite", recipe: ["fire", "earth"] },
wood: { name: "Wood", sym: "🪵", color: "#8B5E3C", glow: "#8B5E3C40", cat: "composite", recipe: ["life", "earth"] },
sand: { name: "Sand", sym: "⏳", color: "#D4AC0D", glow: "#D4AC0D40", cat: "composite", recipe: ["earth", "water"] },
// Exotic Elements
crystal: { name: "Crystal", sym: "💎", color: "#85C1E9", glow: "#85C1E940", cat: "exotic", recipe: ["sand", "sand", "mental"] },
stellar: { name: "Stellar", sym: "⭐", color: "#F0E68C", glow: "#F0E68C40", cat: "exotic", recipe: ["fire", "fire", "light"] },
void: { name: "Void", sym: "🕳️", color: "#4A235A", glow: "#4A235A40", cat: "exotic", recipe: ["dark", "dark", "death"] },
};
export const FLOOR_ELEM_CYCLE = ["fire", "water", "air", "earth", "light", "dark", "life", "death"];
// ─── Guardians ────────────────────────────────────────────────────────────────
export const GUARDIANS: Record<number, GuardianDef> = {
10: {
name: "Ignis Prime", element: "fire", hp: 5000, pact: 1.5, color: "#FF6B35",
boons: [
{ type: 'elementalDamage', value: 5, desc: '+5% Fire damage' },
{ type: 'maxMana', value: 50, desc: '+50 max mana' },
],
pactCost: 500,
pactTime: 2,
uniquePerk: "Fire spells cast 10% faster"
},
20: {
name: "Aqua Regia", element: "water", hp: 15000, pact: 1.75, color: "#4ECDC4",
boons: [
{ type: 'elementalDamage', value: 5, desc: '+5% Water damage' },
{ type: 'manaRegen', value: 0.5, desc: '+0.5 mana regen' },
],
pactCost: 1000,
pactTime: 4,
uniquePerk: "Water spells have 10% lifesteal"
},
30: {
name: "Ventus Rex", element: "air", hp: 30000, pact: 2.0, color: "#00D4FF",
boons: [
{ type: 'elementalDamage', value: 5, desc: '+5% Air damage' },
{ type: 'castingSpeed', value: 5, desc: '+5% cast speed' },
],
pactCost: 2000,
pactTime: 6,
uniquePerk: "Air spells have 15% crit chance"
},
40: {
name: "Terra Firma", element: "earth", hp: 50000, pact: 2.25, color: "#F4A261",
boons: [
{ type: 'elementalDamage', value: 5, desc: '+5% Earth damage' },
{ type: 'maxMana', value: 100, desc: '+100 max mana' },
],
pactCost: 4000,
pactTime: 8,
uniquePerk: "Earth spells deal +25% damage to guardians"
},
50: {
name: "Lux Aeterna", element: "light", hp: 80000, pact: 2.5, color: "#FFD700",
boons: [
{ type: 'elementalDamage', value: 10, desc: '+10% Light damage' },
{ type: 'insightGain', value: 10, desc: '+10% insight gain' },
],
pactCost: 8000,
pactTime: 10,
uniquePerk: "Light spells reveal enemy weaknesses (+20% damage)"
},
60: {
name: "Umbra Mortis", element: "dark", hp: 120000, pact: 2.75, color: "#9B59B6",
boons: [
{ type: 'elementalDamage', value: 10, desc: '+10% Dark damage' },
{ type: 'critDamage', value: 15, desc: '+15% crit damage' },
],
pactCost: 15000,
pactTime: 12,
uniquePerk: "Dark spells have 20% lifesteal"
},
70: {
name: "Vita Sempiterna", element: "life", hp: 180000, pact: 3.0, color: "#2ECC71",
boons: [
{ type: 'elementalDamage', value: 10, desc: '+10% Life damage' },
{ type: 'manaRegen', value: 1, desc: '+1 mana regen' },
],
pactCost: 25000,
pactTime: 14,
uniquePerk: "Life spells heal for 30% of damage dealt"
},
80: {
name: "Mors Ultima", element: "death", hp: 250000, pact: 3.25, color: "#778CA3",
boons: [
{ type: 'elementalDamage', value: 10, desc: '+10% Death damage' },
{ type: 'rawDamage', value: 10, desc: '+10% raw damage' },
],
pactCost: 40000,
pactTime: 16,
uniquePerk: "Death spells execute enemies below 20% HP"
},
90: {
name: "Primordialis", element: "void", hp: 400000, pact: 4.0, color: "#4A235A",
boons: [
{ type: 'elementalDamage', value: 15, desc: '+15% Void damage' },
{ type: 'maxMana', value: 200, desc: '+200 max mana' },
{ type: 'manaRegen', value: 1, desc: '+1 mana regen' },
],
pactCost: 75000,
pactTime: 20,
uniquePerk: "Void spells ignore 30% of enemy resistance"
},
100: {
name: "The Awakened One", element: "stellar", hp: 1000000, pact: 5.0, color: "#F0E68C",
boons: [
{ type: 'elementalDamage', value: 20, desc: '+20% Stellar damage' },
{ type: 'maxMana', value: 500, desc: '+500 max mana' },
{ type: 'manaRegen', value: 2, desc: '+2 mana regen' },
{ type: 'insightGain', value: 25, desc: '+25% insight gain' },
],
pactCost: 150000,
pactTime: 24,
uniquePerk: "All spells deal +50% damage and cast 25% faster"
},
};
// ─── Spells ───────────────────────────────────────────────────────────────────
export const SPELLS_DEF: Record<string, SpellDef> = {
// Tier 0 - Basic Raw Mana Spells (fast, costs raw mana)
manaBolt: {
name: "Mana Bolt",
elem: "raw",
dmg: 5,
cost: rawCost(3),
tier: 0,
castSpeed: 3,
unlock: 0,
studyTime: 0,
desc: "A weak bolt of pure mana. Costs raw mana instead of elemental."
},
manaStrike: {
name: "Mana Strike",
elem: "raw",
dmg: 8,
cost: rawCost(5),
tier: 0,
castSpeed: 2.5,
unlock: 50,
studyTime: 1,
desc: "A concentrated strike of raw mana. Slightly stronger than Mana Bolt."
},
// Tier 1 - Basic Elemental Spells (2-4 hours study)
fireball: {
name: "Fireball",
elem: "fire",
dmg: 15,
cost: elemCost("fire", 2),
tier: 1,
castSpeed: 2,
unlock: 100,
studyTime: 2,
desc: "Hurl a ball of fire at your enemy."
},
emberShot: {
name: "Ember Shot",
elem: "fire",
dmg: 10,
cost: elemCost("fire", 1),
tier: 1,
castSpeed: 3,
unlock: 75,
studyTime: 1,
desc: "A quick shot of embers. Efficient fire damage."
},
waterJet: {
name: "Water Jet",
elem: "water",
dmg: 12,
cost: elemCost("water", 2),
tier: 1,
castSpeed: 2,
unlock: 100,
studyTime: 2,
desc: "A high-pressure jet of water."
},
iceShard: {
name: "Ice Shard",
elem: "water",
dmg: 14,
cost: elemCost("water", 2),
tier: 1,
castSpeed: 2,
unlock: 120,
studyTime: 2,
desc: "Launch a sharp shard of ice."
},
gust: {
name: "Gust",
elem: "air",
dmg: 10,
cost: elemCost("air", 2),
tier: 1,
castSpeed: 3,
unlock: 100,
studyTime: 2,
desc: "A powerful gust of wind."
},
windSlash: {
name: "Wind Slash",
elem: "air",
dmg: 12,
cost: elemCost("air", 2),
tier: 1,
castSpeed: 2.5,
unlock: 110,
studyTime: 2,
desc: "A cutting blade of wind."
},
stoneBullet: {
name: "Stone Bullet",
elem: "earth",
dmg: 16,
cost: elemCost("earth", 2),
tier: 1,
castSpeed: 2,
unlock: 150,
studyTime: 3,
desc: "Launch a bullet of solid stone."
},
rockSpike: {
name: "Rock Spike",
elem: "earth",
dmg: 18,
cost: elemCost("earth", 3),
tier: 1,
castSpeed: 1.5,
unlock: 180,
studyTime: 3,
desc: "Summon a spike of rock from below."
},
lightLance: {
name: "Light Lance",
elem: "light",
dmg: 18,
cost: elemCost("light", 2),
tier: 1,
castSpeed: 2,
unlock: 200,
studyTime: 4,
desc: "A piercing lance of pure light."
},
radiance: {
name: "Radiance",
elem: "light",
dmg: 14,
cost: elemCost("light", 2),
tier: 1,
castSpeed: 2.5,
unlock: 180,
studyTime: 3,
desc: "Burst of radiant energy."
},
shadowBolt: {
name: "Shadow Bolt",
elem: "dark",
dmg: 16,
cost: elemCost("dark", 2),
tier: 1,
castSpeed: 2,
unlock: 200,
studyTime: 4,
desc: "A bolt of shadowy energy."
},
darkPulse: {
name: "Dark Pulse",
elem: "dark",
dmg: 12,
cost: elemCost("dark", 1),
tier: 1,
castSpeed: 3,
unlock: 150,
studyTime: 2,
desc: "A quick pulse of darkness."
},
drain: {
name: "Drain",
elem: "death",
dmg: 10,
cost: elemCost("death", 2),
tier: 1,
castSpeed: 2,
unlock: 150,
studyTime: 3,
desc: "Drain life force from your enemy.",
effects: [{ type: 'lifesteal', value: 0.2 }]
},
rotTouch: {
name: "Rot Touch",
elem: "death",
dmg: 14,
cost: elemCost("death", 2),
tier: 1,
castSpeed: 2,
unlock: 170,
studyTime: 3,
desc: "Touch of decay and rot."
},
lifeTap: {
name: "Life Tap",
elem: "life",
dmg: 8,
cost: elemCost("life", 1),
tier: 1,
castSpeed: 3,
unlock: 100,
studyTime: 2,
desc: "Tap into life energy for a weak attack.",
effects: [{ type: 'lifesteal', value: 0.3 }]
},
thornWhip: {
name: "Thorn Whip",
elem: "life",
dmg: 12,
cost: elemCost("life", 2),
tier: 1,
castSpeed: 2.5,
unlock: 130,
studyTime: 2,
desc: "A whip of living thorns."
},
// Tier 2 - Advanced Spells (8-12 hours study)
inferno: {
name: "Inferno",
elem: "fire",
dmg: 60,
cost: elemCost("fire", 8),
tier: 2,
castSpeed: 1,
unlock: 1000,
studyTime: 8,
desc: "Engulf your enemy in flames."
},
flameWave: {
name: "Flame Wave",
elem: "fire",
dmg: 45,
cost: elemCost("fire", 6),
tier: 2,
castSpeed: 1.5,
unlock: 800,
studyTime: 6,
desc: "A wave of fire sweeps across the battlefield."
},
tidalWave: {
name: "Tidal Wave",
elem: "water",
dmg: 55,
cost: elemCost("water", 8),
tier: 2,
castSpeed: 1,
unlock: 1000,
studyTime: 8,
desc: "A massive wave crashes down."
},
iceStorm: {
name: "Ice Storm",
elem: "water",
dmg: 50,
cost: elemCost("water", 7),
tier: 2,
castSpeed: 1.2,
unlock: 900,
studyTime: 7,
desc: "A storm of ice shards."
},
earthquake: {
name: "Earthquake",
elem: "earth",
dmg: 70,
cost: elemCost("earth", 10),
tier: 2,
castSpeed: 0.8,
unlock: 1200,
studyTime: 10,
desc: "Shake the very foundation."
},
stoneBarrage: {
name: "Stone Barrage",
elem: "earth",
dmg: 55,
cost: elemCost("earth", 7),
tier: 2,
castSpeed: 1.2,
unlock: 1000,
studyTime: 8,
desc: "Multiple stone projectiles."
},
hurricane: {
name: "Hurricane",
elem: "air",
dmg: 50,
cost: elemCost("air", 8),
tier: 2,
castSpeed: 1,
unlock: 1000,
studyTime: 8,
desc: "A devastating hurricane."
},
windBlade: {
name: "Wind Blade",
elem: "air",
dmg: 40,
cost: elemCost("air", 5),
tier: 2,
castSpeed: 1.8,
unlock: 700,
studyTime: 6,
desc: "A blade of cutting wind."
},
solarFlare: {
name: "Solar Flare",
elem: "light",
dmg: 65,
cost: elemCost("light", 9),
tier: 2,
castSpeed: 0.9,
unlock: 1100,
studyTime: 9,
desc: "A blinding flare of solar energy."
},
divineSmite: {
name: "Divine Smite",
elem: "light",
dmg: 55,
cost: elemCost("light", 7),
tier: 2,
castSpeed: 1.2,
unlock: 900,
studyTime: 7,
desc: "A smite of divine power."
},
voidRift: {
name: "Void Rift",
elem: "dark",
dmg: 55,
cost: elemCost("dark", 8),
tier: 2,
castSpeed: 1,
unlock: 1000,
studyTime: 8,
desc: "Open a rift to the void."
},
shadowStorm: {
name: "Shadow Storm",
elem: "dark",
dmg: 48,
cost: elemCost("dark", 6),
tier: 2,
castSpeed: 1.3,
unlock: 800,
studyTime: 6,
desc: "A storm of shadows."
},
soulRend: {
name: "Soul Rend",
elem: "death",
dmg: 50,
cost: elemCost("death", 7),
tier: 2,
castSpeed: 1.1,
unlock: 1100,
studyTime: 9,
desc: "Tear at the enemy's soul.",
effects: [{ type: 'lifesteal', value: 0.25 }]
},
entangle: {
name: "Entangle",
elem: "life",
dmg: 35,
cost: elemCost("life", 5),
tier: 2,
castSpeed: 1.5,
unlock: 700,
studyTime: 6,
desc: "Vines entangle and crush."
},
// Tier 3 - Master Spells (20-30 hours study)
pyroclasm: {
name: "Pyroclasm",
elem: "fire",
dmg: 250,
cost: elemCost("fire", 25),
tier: 3,
castSpeed: 0.6,
unlock: 10000,
studyTime: 24,
desc: "An eruption of volcanic fury."
},
tsunami: {
name: "Tsunami",
elem: "water",
dmg: 220,
cost: elemCost("water", 22),
tier: 3,
castSpeed: 0.65,
unlock: 10000,
studyTime: 24,
desc: "A towering wall of water."
},
meteorStrike: {
name: "Meteor Strike",
elem: "earth",
dmg: 280,
cost: elemCost("earth", 28),
tier: 3,
castSpeed: 0.5,
unlock: 12000,
studyTime: 28,
desc: "Call down a meteor from the heavens."
},
cosmicStorm: {
name: "Cosmic Storm",
elem: "air",
dmg: 200,
cost: elemCost("air", 20),
tier: 3,
castSpeed: 0.7,
unlock: 10000,
studyTime: 24,
desc: "A storm of cosmic proportions."
},
heavenLight: {
name: "Heaven's Light",
elem: "light",
dmg: 240,
cost: elemCost("light", 24),
tier: 3,
castSpeed: 0.6,
unlock: 11000,
studyTime: 26,
desc: "The light of heaven itself."
},
oblivion: {
name: "Oblivion",
elem: "dark",
dmg: 230,
cost: elemCost("dark", 23),
tier: 3,
castSpeed: 0.6,
unlock: 10500,
studyTime: 25,
desc: "Consign to oblivion."
},
deathMark: {
name: "Death Mark",
elem: "death",
dmg: 200,
cost: elemCost("death", 20),
tier: 3,
castSpeed: 0.7,
unlock: 10000,
studyTime: 24,
desc: "Mark for death.",
effects: [{ type: 'lifesteal', value: 0.35 }]
},
worldTree: {
name: "World Tree",
elem: "life",
dmg: 180,
cost: elemCost("life", 18),
tier: 3,
castSpeed: 0.75,
unlock: 9000,
studyTime: 22,
desc: "Power of the world tree itself.",
effects: [{ type: 'lifesteal', value: 0.4 }]
},
// Tier 4 - Legendary Spells (40-60 hours study, require exotic elements)
stellarNova: {
name: "Stellar Nova",
elem: "stellar",
dmg: 500,
cost: elemCost("stellar", 15),
tier: 4,
castSpeed: 0.4,
unlock: 50000,
studyTime: 48,
desc: "A nova of stellar energy."
},
voidCollapse: {
name: "Void Collapse",
elem: "void",
dmg: 450,
cost: elemCost("void", 12),
tier: 4,
castSpeed: 0.45,
unlock: 40000,
studyTime: 42,
desc: "Collapse the void upon your enemy."
},
crystalShatter: {
name: "Crystal Shatter",
elem: "crystal",
dmg: 400,
cost: elemCost("crystal", 10),
tier: 4,
castSpeed: 0.5,
unlock: 35000,
studyTime: 36,
desc: "Shatter crystalline energy."
},
};
// ─── Skills ───────────────────────────────────────────────────────────────────
export const SKILLS_DEF: Record<string, SkillDef> = {
// Mana Skills (4-8 hours study)
manaWell: { name: "Mana Well", desc: "+100 max mana", cat: "mana", max: 10, base: 100, studyTime: 4 },
manaFlow: { name: "Mana Flow", desc: "+1 regen/hr", cat: "mana", max: 10, base: 150, studyTime: 5 },
elemAttune: { name: "Elem. Attunement", desc: "+50 elem mana cap", cat: "mana", max: 10, base: 200, studyTime: 4 },
manaOverflow: { name: "Mana Overflow", desc: "+25% mana from clicks", cat: "mana", max: 5, base: 400, req: { manaWell: 3 }, studyTime: 6 },
// Study Skills (3-6 hours study)
quickLearner: { name: "Quick Learner", desc: "+10% study speed", cat: "study", max: 10, base: 250, studyTime: 4 },
focusedMind: { name: "Focused Mind", desc: "-5% study mana cost", cat: "study", max: 10, base: 300, studyTime: 5 },
meditation: { name: "Meditation Focus", desc: "Up to 2.5x regen after 4hrs meditating", cat: "study", max: 1, base: 400, studyTime: 6 },
knowledgeRetention: { name: "Knowledge Retention", desc: "+20% study progress saved on cancel", cat: "study", max: 3, base: 350, studyTime: 5 },
// Enchanting Skills (4-8 hours study) - Core crafting system
enchanting: { name: "Enchanting", desc: "Unlocks enchantment design", cat: "enchant", max: 10, base: 200, studyTime: 5 },
efficientEnchant:{ name: "Efficient Enchant", desc: "-5% enchantment capacity cost", cat: "enchant", max: 5, base: 350, studyTime: 6, req: { enchanting: 3 } },
disenchanting: { name: "Disenchanting", desc: "Recover 20% mana from removed enchantments", cat: "enchant", max: 3, base: 400, studyTime: 6, req: { enchanting: 2 } },
enchantSpeed: { name: "Enchant Speed", desc: "-10% enchantment time", cat: "enchant", max: 5, base: 300, studyTime: 4, req: { enchanting: 2 } },
scrollCrafting: { name: "Scroll Crafting", desc: "Create scrolls to store enchantment designs", cat: "enchant", max: 3, base: 500, studyTime: 8, req: { enchanting: 5 } },
essenceRefining: { name: "Essence Refining", desc: "+10% enchantment effect power", cat: "enchant", max: 5, base: 450, studyTime: 7, req: { enchanting: 4 } },
// Crafting Skills (4-6 hours study)
effCrafting: { name: "Eff. Crafting", desc: "-10% craft time", cat: "craft", max: 5, base: 300, studyTime: 4 },
fieldRepair: { name: "Field Repair", desc: "+15% repair efficiency", cat: "craft", max: 5, base: 350, studyTime: 4 },
elemCrafting: { name: "Elem. Crafting", desc: "+25% craft output", cat: "craft", max: 3, base: 500, req: { effCrafting: 3 }, studyTime: 8 },
// Effect Research Skills (unlock enchantment effects for designing)
// Tier 1 - Basic Spell Effects
researchManaSpells: { name: "Mana Spell Research", desc: "Unlock Mana Strike spell enchantment", cat: "effectResearch", max: 1, base: 200, studyTime: 4, req: { enchanting: 1 } },
researchFireSpells: { name: "Fire Spell Research", desc: "Unlock Ember Shot, Fireball spell enchantments", cat: "effectResearch", max: 1, base: 300, studyTime: 6, req: { enchanting: 2 } },
researchWaterSpells: { name: "Water Spell Research", desc: "Unlock Water Jet, Ice Shard spell enchantments", cat: "effectResearch", max: 1, base: 300, studyTime: 6, req: { enchanting: 2 } },
researchAirSpells: { name: "Air Spell Research", desc: "Unlock Gust, Wind Slash spell enchantments", cat: "effectResearch", max: 1, base: 300, studyTime: 6, req: { enchanting: 2 } },
researchEarthSpells: { name: "Earth Spell Research", desc: "Unlock Stone Bullet, Rock Spike spell enchantments", cat: "effectResearch", max: 1, base: 350, studyTime: 6, req: { enchanting: 2 } },
researchLightSpells: { name: "Light Spell Research", desc: "Unlock Light Lance, Radiance spell enchantments", cat: "effectResearch", max: 1, base: 400, studyTime: 8, req: { enchanting: 3 } },
researchDarkSpells: { name: "Dark Spell Research", desc: "Unlock Shadow Bolt, Dark Pulse spell enchantments", cat: "effectResearch", max: 1, base: 400, studyTime: 8, req: { enchanting: 3 } },
researchLifeDeathSpells: { name: "Life & Death Research", desc: "Unlock Drain, Life Tap spell enchantments", cat: "effectResearch", max: 1, base: 400, studyTime: 8, req: { enchanting: 3 } },
// Tier 2 - Advanced Spell Effects
researchAdvancedFire: { name: "Advanced Fire Research", desc: "Unlock Inferno, Flame Wave spell enchantments", cat: "effectResearch", max: 1, base: 600, studyTime: 12, req: { researchFireSpells: 1, enchanting: 4 } },
researchAdvancedWater: { name: "Advanced Water Research", desc: "Unlock Tidal Wave, Ice Storm spell enchantments", cat: "effectResearch", max: 1, base: 600, studyTime: 12, req: { researchWaterSpells: 1, enchanting: 4 } },
researchAdvancedAir: { name: "Advanced Air Research", desc: "Unlock Hurricane, Wind Blade spell enchantments", cat: "effectResearch", max: 1, base: 600, studyTime: 12, req: { researchAirSpells: 1, enchanting: 4 } },
researchAdvancedEarth: { name: "Advanced Earth Research", desc: "Unlock Earthquake, Stone Barrage spell enchantments", cat: "effectResearch", max: 1, base: 600, studyTime: 12, req: { researchEarthSpells: 1, enchanting: 4 } },
researchAdvancedLight: { name: "Advanced Light Research", desc: "Unlock Solar Flare, Divine Smite spell enchantments", cat: "effectResearch", max: 1, base: 700, studyTime: 14, req: { researchLightSpells: 1, enchanting: 5 } },
researchAdvancedDark: { name: "Advanced Dark Research", desc: "Unlock Void Rift, Shadow Storm spell enchantments", cat: "effectResearch", max: 1, base: 700, studyTime: 14, req: { researchDarkSpells: 1, enchanting: 5 } },
// Tier 3 - Master Spell Effects
researchMasterFire: { name: "Master Fire Research", desc: "Unlock Pyroclasm spell enchantment", cat: "effectResearch", max: 1, base: 1200, studyTime: 24, req: { researchAdvancedFire: 1, enchanting: 7 } },
researchMasterWater: { name: "Master Water Research", desc: "Unlock Tsunami spell enchantment", cat: "effectResearch", max: 1, base: 1200, studyTime: 24, req: { researchAdvancedWater: 1, enchanting: 7 } },
researchMasterEarth: { name: "Master Earth Research", desc: "Unlock Meteor Strike spell enchantment", cat: "effectResearch", max: 1, base: 1300, studyTime: 26, req: { researchAdvancedEarth: 1, enchanting: 8 } },
// Combat Effect Research
researchDamageEffects: { name: "Damage Effect Research", desc: "Unlock Minor/Moderate Power, Amplification effects", cat: "effectResearch", max: 1, base: 250, studyTime: 5, req: { enchanting: 1 } },
researchCombatEffects: { name: "Combat Effect Research", desc: "Unlock Sharp Edge, Swift Casting effects", cat: "effectResearch", max: 1, base: 350, studyTime: 6, req: { researchDamageEffects: 1, enchanting: 3 } },
// Mana Effect Research
researchManaEffects: { name: "Mana Effect Research", desc: "Unlock Mana Reserve, Trickle, Mana Tap effects", cat: "effectResearch", max: 1, base: 200, studyTime: 4, req: { enchanting: 1 } },
researchAdvancedManaEffects: { name: "Advanced Mana Research", desc: "Unlock Mana Reservoir, Stream, River, Mana Surge effects", cat: "effectResearch", max: 1, base: 400, studyTime: 8, req: { researchManaEffects: 1, enchanting: 3 } },
// Utility Effect Research
researchUtilityEffects: { name: "Utility Effect Research", desc: "Unlock Meditative Focus, Quick Study, Insightful effects", cat: "effectResearch", max: 1, base: 300, studyTime: 6, req: { enchanting: 2 } },
// Special Effect Research
researchSpecialEffects: { name: "Special Effect Research", desc: "Unlock Echo Chamber, Siphoning, Bane effects", cat: "effectResearch", max: 1, base: 500, studyTime: 10, req: { enchanting: 4 } },
researchOverpower: { name: "Overpower Research", desc: "Unlock Overpower effect", cat: "effectResearch", max: 1, base: 700, studyTime: 12, req: { researchSpecialEffects: 1, enchanting: 5 } },
// Research Skills (longer study times: 12-72 hours)
manaTap: { name: "Mana Tap", desc: "+1 mana/click", cat: "research", max: 1, base: 300, studyTime: 12 },
manaSurge: { name: "Mana Surge", desc: "+3 mana/click", cat: "research", max: 1, base: 800, studyTime: 36, req: { manaTap: 1 } },
manaSpring: { name: "Mana Spring", desc: "+2 mana regen", cat: "research", max: 1, base: 600, studyTime: 24 },
deepTrance: { name: "Deep Trance", desc: "Extend meditation bonus to 6hrs for 3x", cat: "research", max: 1, base: 900, studyTime: 48, req: { meditation: 1 } },
voidMeditation:{ name: "Void Meditation", desc: "Extend meditation bonus to 8hrs for 5x", cat: "research", max: 1, base: 1500, studyTime: 72, req: { deepTrance: 1 } },
// Ascension Skills (very long study, powerful effects)
insightHarvest: { name: "Insight Harvest", desc: "+10% insight gain", cat: "ascension", max: 5, base: 1000, studyTime: 20 },
temporalMemory: { name: "Temporal Memory", desc: "Keep 1 spell learned across loops", cat: "ascension", max: 3, base: 2000, studyTime: 36 },
guardianBane: { name: "Guardian Bane", desc: "+20% dmg vs guardians", cat: "ascension", max: 3, base: 1500, studyTime: 30 },
};
// ─── Prestige Upgrades ────────────────────────────────────────────────────────
export const PRESTIGE_DEF: Record<string, PrestigeDef> = {
manaWell: { name: "Mana Well", desc: "+500 starting max mana", max: 5, cost: 500 },
manaFlow: { name: "Mana Flow", desc: "+0.5 regen/sec permanently", max: 10, cost: 750 },
deepMemory: { name: "Deep Memory", desc: "+1 memory slot", max: 5, cost: 1000 },
insightAmp: { name: "Insight Amp", desc: "+25% insight gain", max: 4, cost: 1500 },
spireKey: { name: "Spire Key", desc: "Start at floor +2", max: 5, cost: 4000 },
temporalEcho: { name: "Temporal Echo", desc: "+10% mana generation", max: 5, cost: 3000 },
steadyHand: { name: "Steady Hand", desc: "-15% durability loss", max: 5, cost: 1200 },
ancientKnowledge: { name: "Ancient Knowledge", desc: "Start with blueprint discovered", max: 5, cost: 2000 },
elementalAttune: { name: "Elemental Attunement", desc: "+25 elemental mana cap", max: 10, cost: 600 },
spellMemory: { name: "Spell Memory", desc: "Start with random spell learned", max: 3, cost: 2500 },
guardianPact: { name: "Guardian Pact", desc: "+10% pact multiplier", max: 5, cost: 3500 },
quickStart: { name: "Quick Start", desc: "Start with 100 raw mana", max: 3, cost: 400 },
elemStart: { name: "Elem. Start", desc: "Start with 5 of each unlocked element", max: 3, cost: 800 },
};
// ─── Skill Categories ─────────────────────────────────────────────────────────
// Skills are now organized by attunement - each attunement grants access to specific skill categories
export const SKILL_CATEGORIES = [
// Core categories (always available)
{ id: 'mana', name: 'Mana', icon: '💧', attunement: null },
{ id: 'study', name: 'Study', icon: '📚', attunement: null },
{ id: 'research', name: 'Research', icon: '🔮', attunement: null },
{ id: 'ascension', name: 'Ascension', icon: '⭐', attunement: null },
// Enchanter attunement (Right Hand)
{ id: 'enchant', name: 'Enchanting', icon: '✨', attunement: 'enchanter' },
{ id: 'effectResearch', name: 'Effect Research', icon: '🔬', attunement: 'enchanter' },
// Invoker attunement (Chest)
{ id: 'invocation', name: 'Invocation', icon: '💜', attunement: 'invoker' },
{ id: 'pact', name: 'Pact Mastery', icon: '🤝', attunement: 'invoker' },
// Fabricator attunement (Left Hand)
{ id: 'fabrication', name: 'Fabrication', icon: '⚒️', attunement: 'fabricator' },
{ id: 'golemancy', name: 'Golemancy', icon: '🗿', attunement: 'fabricator' },
// Legacy category (for backward compatibility)
{ id: 'craft', name: 'Crafting', icon: '🔧', attunement: null },
];
// ─── Rarity Colors ───────────────────────────────────────────────────────────
export const RARITY_COLORS: Record<string, string> = {
common: '#9CA3AF',
uncommon: '#22C55E',
rare: '#3B82F6',
epic: '#A855F7',
legendary: '#F97316',
mythic: '#EF4444',
};
// ─── Effect Research Mapping ───────────────────────────────────────────────────
// Maps research skill IDs to the effect IDs they unlock
export const EFFECT_RESEARCH_MAPPING: Record<string, string[]> = {
// Tier 1 - Basic Spell Effects
researchManaSpells: ['spell_manaStrike'],
researchFireSpells: ['spell_emberShot', 'spell_fireball'],
researchWaterSpells: ['spell_waterJet', 'spell_iceShard'],
researchAirSpells: ['spell_gust', 'spell_windSlash'],
researchEarthSpells: ['spell_stoneBullet', 'spell_rockSpike'],
researchLightSpells: ['spell_lightLance', 'spell_radiance'],
researchDarkSpells: ['spell_shadowBolt', 'spell_darkPulse'],
researchLifeDeathSpells: ['spell_drain', 'spell_lifeTap'],
// Tier 2 - Advanced Spell Effects
researchAdvancedFire: ['spell_inferno', 'spell_flameWave'],
researchAdvancedWater: ['spell_tidalWave', 'spell_iceStorm'],
researchAdvancedAir: ['spell_hurricane', 'spell_windBlade'],
researchAdvancedEarth: ['spell_earthquake', 'spell_stoneBarrage'],
researchAdvancedLight: ['spell_solarFlare', 'spell_divineSmite'],
researchAdvancedDark: ['spell_voidRift', 'spell_shadowStorm'],
// Tier 3 - Master Spell Effects
researchMasterFire: ['spell_pyroclasm'],
researchMasterWater: ['spell_tsunami'],
researchMasterEarth: ['spell_meteorStrike'],
// Combat Effect Research
researchDamageEffects: ['damage_5', 'damage_10', 'damage_pct_10'],
researchCombatEffects: ['crit_5', 'attack_speed_10'],
// Mana Effect Research
researchManaEffects: ['mana_cap_50', 'mana_regen_1', 'click_mana_1'],
researchAdvancedManaEffects: ['mana_cap_100', 'mana_regen_2', 'mana_regen_5', 'click_mana_3'],
// Utility Effect Research
researchUtilityEffects: ['meditate_10', 'study_10', 'insight_5'],
// Special Effect Research
researchSpecialEffects: ['spell_echo_10', 'lifesteal_5', 'guardian_dmg_10'],
researchOverpower: ['overpower_80'],
};
// Base effects unlocked when player gets enchanting skill level 1
export const BASE_UNLOCKED_EFFECTS: string[] = []; // No effects at game start
// Effects that unlock when getting enchanting skill level 1
export const ENCHANTING_UNLOCK_EFFECTS = ['spell_manaBolt'];
// ─── Base Unlocked Elements ───────────────────────────────────────────────────
export const BASE_UNLOCKED_ELEMENTS = ['fire', 'water', 'air', 'earth'];
// ─── Study Speed Formula ─────────────────────────────────────────────────────
export function getStudySpeedMultiplier(skills: Record<string, number>): number {
return 1 + (skills.quickLearner || 0) * 0.1;
}
// ─── Study Cost Formula ───────────────────────────────────────────────────────
export function getStudyCostMultiplier(skills: Record<string, number>): number {
return 1 - (skills.focusedMind || 0) * 0.05;
}
// ─── Element Opposites for Damage Calculation ────────────────────────────────
export const ELEMENT_OPPOSITES: Record<string, string> = {
fire: 'water', water: 'fire',
air: 'earth', earth: 'air',
light: 'dark', dark: 'light',
life: 'death', death: 'life',
};
// ─── Element Icon Mapping (Lucide Icons) ──────────────────────────────────────
// Note: These are string identifiers for dynamic icon loading
// The actual Lucide icons are imported in the components
export const ELEMENT_ICON_NAMES: Record<string, string> = {
fire: 'Flame',
water: 'Droplet',
air: 'Wind',
earth: 'Mountain',
light: 'Sun',
dark: 'Moon',
life: 'Leaf',
death: 'Skull',
mental: 'Brain',
transference: 'Link',
force: 'Wind',
blood: 'Droplets',
metal: 'Target',
wood: 'TreeDeciduous',
sand: 'Hourglass',
crystal: 'Gem',
stellar: 'Star',
void: 'CircleDot',
raw: 'Circle',
};

827
src/lib/game/crafting-slice.ts Executable file
View File

@@ -0,0 +1,827 @@
// ─── Crafting Store Slice ─────────────────────────────────────────────────────────
// Handles equipment and enchantment system: design, prepare, apply stages
import type { GameState, EquipmentInstance, AppliedEnchantment, EnchantmentDesign, DesignEffect, EquipmentSlot, EquipmentCraftingProgress, LootInventory, AttunementState } from './types';
import { EQUIPMENT_TYPES, type EquipmentCategory } from './data/equipment';
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
import { CRAFTING_RECIPES, canCraftRecipe, type CraftingRecipe } from './data/crafting-recipes';
import { SPELLS_DEF } from './constants';
import { calculateEnchantingXP, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from './data/attunements';
// ─── Helper Functions ─────────────────────────────────────────────────────────
function generateInstanceId(): string {
return `equip_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
// Get equipment category from type
function getEquipmentCategory(typeId: string): EquipmentCategory | null {
const type = EQUIPMENT_TYPES[typeId];
return type?.category || null;
}
// Calculate total capacity cost for a design
function calculateDesignCapacityCost(effects: DesignEffect[], efficiencyBonus: number = 0): number {
return effects.reduce((total, eff) => total + calculateEffectCapacityCost(eff.effectId, eff.stacks, efficiencyBonus), 0);
}
// Calculate design time based on number and complexity of effects
function calculateDesignTime(effects: DesignEffect[]): number {
// Base 1 hour + 0.5 hours per effect stack
let time = 1;
for (const eff of effects) {
const effectDef = ENCHANTMENT_EFFECTS[eff.effectId];
if (effectDef) {
time += 0.5 * eff.stacks;
}
}
return time;
}
// Calculate preparation time based on equipment capacity
function calculatePrepTime(equipmentCapacity: number): number {
// Base 2 hours + 1 hour per 50 capacity
return 2 + Math.floor(equipmentCapacity / 50);
}
// Calculate preparation mana cost
function calculatePrepManaCost(equipmentCapacity: number): number {
// 10 mana per capacity point
return equipmentCapacity * 10;
}
// Calculate application time based on design complexity
function calculateApplicationTime(design: EnchantmentDesign): number {
// 2 hours base + 1 hour per effect stack
return 2 + design.effects.reduce((total, eff) => total + eff.stacks, 0);
}
// Calculate application mana cost per hour
function calculateApplicationManaPerHour(design: EnchantmentDesign): number {
// 20 mana per hour base + 5 per effect stack
return 20 + design.effects.reduce((total, eff) => total + eff.stacks * 5, 0);
}
// ─── Crafting Actions Interface ───────────────────────────────────────────────
export interface CraftingActions {
// Equipment management
createEquipmentInstance: (typeId: string) => string | null;
equipItem: (instanceId: string, slot: EquipmentSlot) => boolean;
unequipItem: (slot: EquipmentSlot) => void;
deleteEquipmentInstance: (instanceId: string) => void;
// Enchantment design
startDesigningEnchantment: (name: string, equipmentTypeId: string, effects: DesignEffect[]) => boolean;
cancelDesign: () => void;
saveDesign: (design: EnchantmentDesign) => void;
deleteDesign: (designId: string) => void;
// Enchantment preparation
startPreparing: (equipmentInstanceId: string) => boolean;
cancelPreparation: () => void;
// Enchantment application
startApplying: (equipmentInstanceId: string, designId: string) => boolean;
pauseApplication: () => void;
resumeApplication: () => void;
cancelApplication: () => void;
// Disenchanting
disenchantEquipment: (instanceId: string) => void;
// Equipment Crafting (from blueprints)
startCraftingEquipment: (blueprintId: string) => boolean;
cancelEquipmentCrafting: () => void;
deleteMaterial: (materialId: string, amount: number) => void;
// Computed getters
getEquipmentSpells: () => string[];
getEquipmentEffects: () => Record<string, number>;
getAvailableCapacity: (instanceId: string) => number;
}
// ─── Initial Equipment Setup ───────────────────────────────────────────────────
export function createStartingEquipment(): {
equippedInstances: Record<string, string | null>;
equipmentInstances: Record<string, EquipmentInstance>;
} {
// Create starting staff with Mana Bolt enchantment
const staffId = generateInstanceId();
const staffInstance: EquipmentInstance = {
instanceId: staffId,
typeId: 'basicStaff',
name: 'Basic Staff',
enchantments: [
{ effectId: 'spell_manaBolt', stacks: 1, actualCost: 50 }
],
usedCapacity: 50,
totalCapacity: 50,
rarity: 'common',
quality: 100,
};
// Create starting clothes
const shirtId = generateInstanceId();
const shirtInstance: EquipmentInstance = {
instanceId: shirtId,
typeId: 'civilianShirt',
name: 'Civilian Shirt',
enchantments: [],
usedCapacity: 0,
totalCapacity: 30,
rarity: 'common',
quality: 100,
};
const shoesId = generateInstanceId();
const shoesInstance: EquipmentInstance = {
instanceId: shoesId,
typeId: 'civilianShoes',
name: 'Civilian Shoes',
enchantments: [],
usedCapacity: 0,
totalCapacity: 15,
rarity: 'common',
quality: 100,
};
return {
equippedInstances: {
mainHand: staffId,
offHand: null,
head: null,
body: shirtId,
hands: null,
feet: shoesId,
accessory1: null,
accessory2: null,
},
equipmentInstances: {
[staffId]: staffInstance,
[shirtId]: shirtInstance,
[shoesId]: shoesInstance,
},
};
}
// ─── Crafting Store Extensions ─────────────────────────────────────────────────
export function createCraftingSlice(
set: (fn: (state: GameState) => Partial<GameState>) => void,
get: () => GameState & CraftingActions
): CraftingActions {
return {
// ─── Equipment Management ─────────────────────────────────────────────────
createEquipmentInstance: (typeId: string) => {
const type = EQUIPMENT_TYPES[typeId];
if (!type) return null;
const instanceId = generateInstanceId();
const instance: EquipmentInstance = {
instanceId,
typeId,
name: type.name,
enchantments: [],
usedCapacity: 0,
totalCapacity: type.baseCapacity,
rarity: 'common',
quality: 100,
};
set((state) => ({
equipmentInstances: {
...state.equipmentInstances,
[instanceId]: instance,
},
}));
return instanceId;
},
equipItem: (instanceId: string, slot: EquipmentSlot) => {
const state = get();
const instance = state.equipmentInstances[instanceId];
if (!instance) return false;
const type = EQUIPMENT_TYPES[instance.typeId];
if (!type) return false;
// Check if equipment can go in this slot
const validSlots = type.category === 'accessory'
? ['accessory1', 'accessory2']
: [type.slot];
if (!validSlots.includes(slot)) return false;
// Check if slot is occupied
const currentEquipped = state.equippedInstances[slot];
if (currentEquipped === instanceId) return true; // Already equipped here
// If this item is equipped elsewhere, unequip it first
let newEquipped = { ...state.equippedInstances };
for (const [s, id] of Object.entries(newEquipped)) {
if (id === instanceId) {
newEquipped[s as EquipmentSlot] = null;
}
}
// Equip to new slot
newEquipped[slot] = instanceId;
set(() => ({ equippedInstances: newEquipped }));
return true;
},
unequipItem: (slot: EquipmentSlot) => {
set((state) => ({
equippedInstances: {
...state.equippedInstances,
[slot]: null,
},
}));
},
deleteEquipmentInstance: (instanceId: string) => {
set((state) => {
// First unequip if equipped
let newEquipped = { ...state.equippedInstances };
for (const [slot, id] of Object.entries(newEquipped)) {
if (id === instanceId) {
newEquipped[slot as EquipmentSlot] = null;
}
}
// Remove from instances
const newInstances = { ...state.equipmentInstances };
delete newInstances[instanceId];
return {
equippedInstances: newEquipped,
equipmentInstances: newInstances,
};
});
},
// ─── Enchantment Design ─────────────────────────────────────────────────
startDesigningEnchantment: (name: string, equipmentTypeId: string, effects: DesignEffect[]) => {
const state = get();
// Check if player has enchanting skill
const enchantingLevel = state.skills.enchanting || 0;
if (enchantingLevel < 1) return false;
// Validate effects for equipment category
const category = getEquipmentCategory(equipmentTypeId);
if (!category) return false;
for (const eff of effects) {
const effectDef = ENCHANTMENT_EFFECTS[eff.effectId];
if (!effectDef) return false;
if (!effectDef.allowedEquipmentCategories.includes(category)) return false;
if (eff.stacks > effectDef.maxStacks) return false;
}
// Calculate capacity cost
const efficiencyBonus = (state.skills.efficientEnchant || 0) * 0.05;
const totalCapacityCost = calculateDesignCapacityCost(effects, efficiencyBonus);
// Create design
const designTime = calculateDesignTime(effects);
const design: EnchantmentDesign = {
id: `design_${Date.now()}`,
name,
equipmentType: equipmentTypeId,
effects,
totalCapacityUsed: totalCapacityCost,
designTime,
created: Date.now(),
};
set(() => ({
currentAction: 'design',
designProgress: {
designId: design.id,
progress: 0,
required: designTime,
},
}));
return true;
},
cancelDesign: () => {
set(() => ({
currentAction: 'meditate',
designProgress: null,
}));
},
saveDesign: (design: EnchantmentDesign) => {
set((state) => ({
enchantmentDesigns: [...state.enchantmentDesigns, design],
designProgress: null,
currentAction: 'meditate',
}));
},
deleteDesign: (designId: string) => {
set((state) => ({
enchantmentDesigns: state.enchantmentDesigns.filter(d => d.id !== designId),
}));
},
// ─── Enchantment Preparation ─────────────────────────────────────────────
startPreparing: (equipmentInstanceId: string) => {
const state = get();
const instance = state.equipmentInstances[equipmentInstanceId];
if (!instance) return false;
const prepTime = calculatePrepTime(instance.totalCapacity);
const manaCost = calculatePrepManaCost(instance.totalCapacity);
if (state.rawMana < manaCost) return false;
set(() => ({
currentAction: 'prepare',
preparationProgress: {
equipmentInstanceId,
progress: 0,
required: prepTime,
manaCostPaid: 0,
},
}));
return true;
},
cancelPreparation: () => {
set(() => ({
currentAction: 'meditate',
preparationProgress: null,
}));
},
// ─── Enchantment Application ─────────────────────────────────────────────
startApplying: (equipmentInstanceId: string, designId: string) => {
const state = get();
const instance = state.equipmentInstances[equipmentInstanceId];
const design = state.enchantmentDesigns.find(d => d.id === designId);
if (!instance || !design) return false;
// Check capacity
if (instance.usedCapacity + design.totalCapacityUsed > instance.totalCapacity) {
return false;
}
const applicationTime = calculateApplicationTime(design);
const manaPerHour = calculateApplicationManaPerHour(design);
set(() => ({
currentAction: 'enchant',
applicationProgress: {
equipmentInstanceId,
designId,
progress: 0,
required: applicationTime,
manaPerHour,
paused: false,
manaSpent: 0,
},
}));
return true;
},
pauseApplication: () => {
set((state) => {
if (!state.applicationProgress) return {};
return {
applicationProgress: {
...state.applicationProgress,
paused: true,
},
};
});
},
resumeApplication: () => {
set((state) => {
if (!state.applicationProgress) return {};
return {
applicationProgress: {
...state.applicationProgress,
paused: false,
},
};
});
},
cancelApplication: () => {
set(() => ({
currentAction: 'meditate',
applicationProgress: null,
}));
},
// ─── Disenchanting ─────────────────────────────────────────────────────────
disenchantEquipment: (instanceId: string) => {
const state = get();
const instance = state.equipmentInstances[instanceId];
if (!instance || instance.enchantments.length === 0) return;
const disenchantLevel = state.skills.disenchanting || 0;
const recoveryRate = 0.1 + disenchantLevel * 0.2; // 10% base + 20% per level
let totalRecovered = 0;
for (const ench of instance.enchantments) {
totalRecovered += Math.floor(ench.actualCost * recoveryRate);
}
set((state) => ({
rawMana: state.rawMana + totalRecovered,
equipmentInstances: {
...state.equipmentInstances,
[instanceId]: {
...instance,
enchantments: [],
usedCapacity: 0,
},
},
log: [`✨ Disenchanted ${instance.name}, recovered ${totalRecovered} mana.`, ...state.log.slice(0, 49)],
}));
},
// ─── Computed Getters ─────────────────────────────────────────────────────
getEquipmentSpells: () => {
const state = get();
const spells: string[] = [];
for (const instanceId of Object.values(state.equippedInstances)) {
if (!instanceId) continue;
const instance = state.equipmentInstances[instanceId];
if (!instance) continue;
for (const ench of instance.enchantments) {
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) {
spells.push(effectDef.effect.spellId);
}
}
}
return [...new Set(spells)]; // Remove duplicates
},
getEquipmentEffects: () => {
const state = get();
const effects: Record<string, number> = {};
for (const instanceId of Object.values(state.equippedInstances)) {
if (!instanceId) continue;
const instance = state.equipmentInstances[instanceId];
if (!instance) continue;
for (const ench of instance.enchantments) {
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
if (!effectDef) continue;
if (effectDef.effect.type === 'bonus' && effectDef.effect.stat && effectDef.effect.value) {
effects[effectDef.effect.stat] = (effects[effectDef.effect.stat] || 0) + effectDef.effect.value * ench.stacks;
}
}
}
return effects;
},
getAvailableCapacity: (instanceId: string) => {
const state = get();
const instance = state.equipmentInstances[instanceId];
if (!instance) return 0;
return instance.totalCapacity - instance.usedCapacity;
},
// ─── Equipment Crafting (from Blueprints) ───────────────────────────────────
startCraftingEquipment: (blueprintId: string) => {
const state = get();
const recipe = CRAFTING_RECIPES[blueprintId];
if (!recipe) return false;
// Check if player has the blueprint
if (!state.lootInventory.blueprints.includes(blueprintId)) return false;
// Check materials
const { canCraft, missingMaterials, missingMana } = canCraftRecipe(
recipe,
state.lootInventory.materials,
state.rawMana
);
if (!canCraft) return false;
// Deduct materials
const newMaterials = { ...state.lootInventory.materials };
for (const [matId, amount] of Object.entries(recipe.materials)) {
newMaterials[matId] = (newMaterials[matId] || 0) - amount;
if (newMaterials[matId] <= 0) {
delete newMaterials[matId];
}
}
// Start crafting progress
set((state) => ({
lootInventory: {
...state.lootInventory,
materials: newMaterials,
},
rawMana: state.rawMana - recipe.manaCost,
currentAction: 'craft',
equipmentCraftingProgress: {
blueprintId,
equipmentTypeId: recipe.equipmentTypeId,
progress: 0,
required: recipe.craftTime,
manaSpent: recipe.manaCost,
},
}));
return true;
},
cancelEquipmentCrafting: () => {
set((state) => {
const progress = state.equipmentCraftingProgress;
if (!progress) return {};
const recipe = CRAFTING_RECIPES[progress.blueprintId];
if (!recipe) return { currentAction: 'meditate', equipmentCraftingProgress: null };
// Refund 50% of mana
const manaRefund = Math.floor(progress.manaSpent * 0.5);
return {
currentAction: 'meditate',
equipmentCraftingProgress: null,
rawMana: state.rawMana + manaRefund,
log: [`🚫 Equipment crafting cancelled. Refunded ${manaRefund} mana.`, ...state.log.slice(0, 49)],
};
});
},
deleteMaterial: (materialId: string, amount: number) => {
set((state) => {
const currentAmount = state.lootInventory.materials[materialId] || 0;
const newAmount = Math.max(0, currentAmount - amount);
const newMaterials = { ...state.lootInventory.materials };
if (newAmount <= 0) {
delete newMaterials[materialId];
} else {
newMaterials[materialId] = newAmount;
}
const dropName = materialId; // Could look up in LOOT_DROPS for proper name
return {
lootInventory: {
...state.lootInventory,
materials: newMaterials,
},
log: [`🗑️ Deleted ${amount}x ${dropName}.`, ...state.log.slice(0, 49)],
};
});
},
};
}
// ─── Tick Processing for Crafting ─────────────────────────────────────────────
export function processCraftingTick(
state: GameState,
effects: { rawMana: number; log: string[] }
): Partial<GameState> {
const { rawMana, log } = effects;
let updates: Partial<GameState> = {};
// Process design progress
if (state.currentAction === 'design' && state.designProgress) {
const progress = state.designProgress.progress + 0.04; // HOURS_PER_TICK
if (progress >= state.designProgress.required) {
// Design complete - but we need the design data to save it
// This will be handled by the UI calling saveDesign
updates = {
...updates,
log: ['✅ Enchantment design complete!', ...log],
};
} else {
updates = {
...updates,
designProgress: {
...state.designProgress,
progress,
},
};
}
}
// Process preparation progress
if (state.currentAction === 'prepare' && state.preparationProgress) {
const prep = state.preparationProgress;
const manaPerHour = calculatePrepManaCost(
state.equipmentInstances[prep.equipmentInstanceId]?.totalCapacity || 50
) / prep.required;
const manaCost = manaPerHour * 0.04; // HOURS_PER_TICK
if (rawMana >= manaCost) {
const progress = prep.progress + 0.04;
const manaCostPaid = prep.manaCostPaid + manaCost;
if (progress >= prep.required) {
updates = {
...updates,
rawMana: rawMana - manaCost,
preparationProgress: null,
currentAction: 'meditate',
log: ['✅ Equipment prepared for enchanting!', ...log],
};
} else {
updates = {
...updates,
rawMana: rawMana - manaCost,
preparationProgress: {
...prep,
progress,
manaCostPaid,
},
};
}
}
}
// Process application progress
if (state.currentAction === 'enchant' && state.applicationProgress && !state.applicationProgress.paused) {
const app = state.applicationProgress;
const manaCost = app.manaPerHour * 0.04; // HOURS_PER_TICK
if (rawMana >= manaCost) {
const progress = app.progress + 0.04;
const manaSpent = app.manaSpent + manaCost;
if (progress >= app.required) {
// Apply the enchantment!
const instance = state.equipmentInstances[app.equipmentInstanceId];
const design = state.enchantmentDesigns.find(d => d.id === app.designId);
if (instance && design) {
const newEnchantments: AppliedEnchantment[] = design.effects.map(eff => ({
effectId: eff.effectId,
stacks: eff.stacks,
actualCost: eff.capacityCost,
}));
// Calculate and grant attunement XP to enchanter
const xpGained = calculateEnchantingXP(design.totalCapacityUsed);
let newAttunements = state.attunements;
if (state.attunements.enchanter?.active && xpGained > 0) {
const enchanterState = state.attunements.enchanter;
let newXP = enchanterState.experience + xpGained;
let newLevel = enchanterState.level;
// Check for level ups
while (newLevel < MAX_ATTUNEMENT_LEVEL) {
const xpNeeded = getAttunementXPForLevel(newLevel + 1);
if (newXP >= xpNeeded) {
newXP -= xpNeeded;
newLevel++;
} else {
break;
}
}
newAttunements = {
...state.attunements,
enchanter: {
...enchanterState,
level: newLevel,
experience: newXP,
},
};
}
updates = {
...updates,
rawMana: rawMana - manaCost,
applicationProgress: null,
currentAction: 'meditate',
attunements: newAttunements,
equipmentInstances: {
...state.equipmentInstances,
[app.equipmentInstanceId]: {
...instance,
enchantments: [...instance.enchantments, ...newEnchantments],
usedCapacity: instance.usedCapacity + design.totalCapacityUsed,
},
},
log: [`✨ Enchantment "${design.name}" applied to ${instance.name}! (+${xpGained} Enchanter XP)`, ...log],
};
}
} else {
updates = {
...updates,
rawMana: rawMana - manaCost,
applicationProgress: {
...app,
progress,
manaSpent,
},
};
}
}
}
// Process equipment crafting progress
if (state.currentAction === 'craft' && state.equipmentCraftingProgress) {
const craft = state.equipmentCraftingProgress;
const progress = craft.progress + 0.04; // HOURS_PER_TICK
if (progress >= craft.required) {
// Crafting complete - create the equipment!
const recipe = CRAFTING_RECIPES[craft.blueprintId];
const equipType = recipe ? EQUIPMENT_TYPES[recipe.equipmentTypeId] : null;
if (recipe && equipType) {
const instanceId = generateInstanceId();
const newInstance: EquipmentInstance = {
instanceId,
typeId: recipe.equipmentTypeId,
name: recipe.name,
enchantments: [],
usedCapacity: 0,
totalCapacity: equipType.baseCapacity,
rarity: recipe.rarity,
quality: 100,
};
updates = {
...updates,
equipmentCraftingProgress: null,
currentAction: 'meditate',
equipmentInstances: {
...state.equipmentInstances,
[instanceId]: newInstance,
},
totalCraftsCompleted: (state.totalCraftsCompleted || 0) + 1,
log: [`🔨 Crafted ${recipe.name}!`, ...log],
};
} else {
updates = {
...updates,
equipmentCraftingProgress: null,
currentAction: 'meditate',
log: ['⚠️ Crafting failed - invalid recipe!', ...log],
};
}
} else {
updates = {
...updates,
equipmentCraftingProgress: {
...craft,
progress,
},
};
}
}
return updates;
}
// ─── Export helper to get equipment instance spells ─────────────────────────────
export function getSpellsFromEquipment(instances: Record<string, EquipmentInstance>, equippedIds: (string | null)[]): string[] {
const spells: string[] = [];
for (const id of equippedIds) {
if (!id) continue;
const instance = instances[id];
if (!instance) continue;
for (const ench of instance.enchantments) {
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) {
spells.push(effectDef.effect.spellId);
}
}
}
return [...new Set(spells)];
}

272
src/lib/game/data/achievements.ts Executable file
View File

@@ -0,0 +1,272 @@
// ─── Achievement Definitions ───────────────────────────────────────────────────
import type { AchievementDef } from '../types';
export const ACHIEVEMENTS: Record<string, AchievementDef> = {
// ─── Combat Achievements ───
firstBlood: {
id: 'firstBlood',
name: 'First Blood',
desc: 'Clear your first floor',
category: 'combat',
requirement: { type: 'floor', value: 2 },
reward: { insight: 10 },
},
floorClimber: {
id: 'floorClimber',
name: 'Floor Climber',
desc: 'Reach floor 10',
category: 'combat',
requirement: { type: 'floor', value: 10 },
reward: { insight: 25, manaBonus: 10 },
},
spireAssault: {
id: 'spireAssault',
name: 'Spire Assault',
desc: 'Reach floor 25',
category: 'combat',
requirement: { type: 'floor', value: 25 },
reward: { insight: 50, damageBonus: 0.05 },
},
towerConqueror: {
id: 'towerConqueror',
name: 'Tower Conqueror',
desc: 'Reach floor 50',
category: 'combat',
requirement: { type: 'floor', value: 50 },
reward: { insight: 100, manaBonus: 50, damageBonus: 0.1 },
},
spireMaster: {
id: 'spireMaster',
name: 'Spire Master',
desc: 'Reach floor 75',
category: 'combat',
requirement: { type: 'floor', value: 75 },
reward: { insight: 200, damageBonus: 0.15, title: 'Spire Master' },
},
apexReached: {
id: 'apexReached',
name: 'Apex Reached',
desc: 'Reach floor 100',
category: 'combat',
requirement: { type: 'floor', value: 100 },
reward: { insight: 500, manaBonus: 200, damageBonus: 0.25, title: 'Apex Climber' },
},
// ─── Combo Achievements ───
comboStarter: {
id: 'comboStarter',
name: 'Combo Starter',
desc: 'Reach a 10-hit combo',
category: 'combat',
requirement: { type: 'combo', value: 10 },
reward: { insight: 15 },
},
comboMaster: {
id: 'comboMaster',
name: 'Combo Master',
desc: 'Reach a 50-hit combo',
category: 'combat',
requirement: { type: 'combo', value: 50 },
reward: { insight: 50, damageBonus: 0.05 },
},
comboLegend: {
id: 'comboLegend',
name: 'Combo Legend',
desc: 'Reach a 100-hit combo',
category: 'combat',
requirement: { type: 'combo', value: 100 },
reward: { insight: 150, damageBonus: 0.1, title: 'Combo Legend' },
},
// ─── Damage Achievements ───
hundredDamage: {
id: 'hundredDamage',
name: 'Heavy Hitter',
desc: 'Deal 100 damage in a single hit',
category: 'combat',
requirement: { type: 'damage', value: 100 },
reward: { insight: 20 },
},
thousandDamage: {
id: 'thousandDamage',
name: 'Devastating Blow',
desc: 'Deal 1,000 damage in a single hit',
category: 'combat',
requirement: { type: 'damage', value: 1000 },
reward: { insight: 75, damageBonus: 0.03 },
},
tenThousandDamage: {
id: 'tenThousandDamage',
name: 'Apocalypse Now',
desc: 'Deal 10,000 damage in a single hit',
category: 'combat',
requirement: { type: 'damage', value: 10000 },
reward: { insight: 200, damageBonus: 0.05, title: 'Apocalypse Bringer' },
},
// ─── Pact Achievements ───
pactSeeker: {
id: 'pactSeeker',
name: 'Pact Seeker',
desc: 'Sign your first guardian pact',
category: 'progression',
requirement: { type: 'pact', value: 1 },
reward: { insight: 30 },
},
pactCollector: {
id: 'pactCollector',
name: 'Pact Collector',
desc: 'Sign 5 guardian pacts',
category: 'progression',
requirement: { type: 'pact', value: 5 },
reward: { insight: 100, manaBonus: 25 },
},
pactMaster: {
id: 'pactMaster',
name: 'Pact Master',
desc: 'Sign all guardian pacts',
category: 'progression',
requirement: { type: 'pact', value: 12 },
reward: { insight: 500, damageBonus: 0.2, title: 'Pact Master' },
},
// ─── Magic Achievements ───
spellCaster: {
id: 'spellCaster',
name: 'Spell Caster',
desc: 'Cast 100 spells',
category: 'magic',
requirement: { type: 'spells', value: 100 },
reward: { insight: 25 },
},
spellWeaver: {
id: 'spellWeaver',
name: 'Spell Weaver',
desc: 'Cast 1,000 spells',
category: 'magic',
requirement: { type: 'spells', value: 1000 },
reward: { insight: 75, regenBonus: 0.5 },
},
spellStorm: {
id: 'spellStorm',
name: 'Spell Storm',
desc: 'Cast 10,000 spells',
category: 'magic',
requirement: { type: 'spells', value: 10000 },
reward: { insight: 200, regenBonus: 1, title: 'Storm Caller' },
},
// ─── Mana Achievements ───
manaPool: {
id: 'manaPool',
name: 'Mana Pool',
desc: 'Accumulate 1,000 total mana',
category: 'magic',
requirement: { type: 'mana', value: 1000 },
reward: { insight: 20 },
},
manaLake: {
id: 'manaLake',
name: 'Mana Lake',
desc: 'Accumulate 100,000 total mana',
category: 'magic',
requirement: { type: 'mana', value: 100000 },
reward: { insight: 100, manaBonus: 50 },
},
manaOcean: {
id: 'manaOcean',
name: 'Mana Ocean',
desc: 'Accumulate 10,000,000 total mana',
category: 'magic',
requirement: { type: 'mana', value: 10000000 },
reward: { insight: 500, manaBonus: 200, title: 'Mana Ocean' },
},
// ─── Crafting Achievements ───
enchanter: {
id: 'enchanter',
name: 'Enchanter',
desc: 'Complete your first enchantment',
category: 'crafting',
requirement: { type: 'craft', value: 1 },
reward: { insight: 30 },
},
masterEnchanter: {
id: 'masterEnchanter',
name: 'Master Enchanter',
desc: 'Complete 10 enchantments',
category: 'crafting',
requirement: { type: 'craft', value: 10 },
reward: { insight: 100, unlockEffect: 'efficiencyBoost' },
},
legendaryEnchanter: {
id: 'legendaryEnchanter',
name: 'Legendary Enchanter',
desc: 'Complete 50 enchantments',
category: 'crafting',
requirement: { type: 'craft', value: 50 },
reward: { insight: 300, title: 'Legendary Enchanter' },
},
// ─── Special Achievements ───
speedRunner: {
id: 'speedRunner',
name: 'Speed Runner',
desc: 'Reach floor 50 in under 5 days',
category: 'special',
requirement: { type: 'time', value: 5, subType: 'floor50' },
reward: { insight: 200, title: 'Speed Demon' },
hidden: true,
},
perfectionist: {
id: 'perfectionist',
name: 'Perfectionist',
desc: 'Reach floor 100 without any guardian pacts',
category: 'special',
requirement: { type: 'floor', value: 100, subType: 'noPacts' },
reward: { insight: 1000, title: 'Perfect Climber' },
hidden: true,
},
survivor: {
id: 'survivor',
name: 'Survivor',
desc: 'Complete a loop during full incursion (day 30+)',
category: 'special',
requirement: { type: 'time', value: 30 },
reward: { insight: 300, manaBonus: 100, title: 'Survivor' },
},
};
// Category colors for UI
export const ACHIEVEMENT_CATEGORY_COLORS: Record<string, string> = {
combat: '#EF4444', // Red
progression: '#F59E0B', // Amber
crafting: '#8B5CF6', // Purple
magic: '#3B82F6', // Blue
special: '#EC4899', // Pink
};
// Get achievements by category
export function getAchievementsByCategory(): Record<string, AchievementDef[]> {
const result: Record<string, AchievementDef[]> = {};
for (const achievement of Object.values(ACHIEVEMENTS)) {
if (!result[achievement.category]) {
result[achievement.category] = [];
}
result[achievement.category].push(achievement);
}
return result;
}
// Check if an achievement should be revealed
export function isAchievementRevealed(
achievement: AchievementDef,
progress: number
): boolean {
if (!achievement.hidden) return true;
// Reveal hidden achievements when at 50% progress
return progress >= achievement.requirement.value * 0.5;
}

185
src/lib/game/data/attunements.ts Executable file
View File

@@ -0,0 +1,185 @@
// ─── Attunement Definitions ─────────────────────────────────────────────────────
// Attunements are class-like abilities tied to body locations
// Each provides unique capabilities, primary mana types, and skill access
import type { AttunementDef, AttunementSlot } from '../types';
// Attunement slot display names
export const ATTUNEMENT_SLOT_NAMES: Record<AttunementSlot, string> = {
rightHand: 'Right Hand',
leftHand: 'Left Hand',
head: 'Head',
back: 'Back',
chest: 'Chest',
leftLeg: 'Left Leg',
rightLeg: 'Right Leg',
};
// All attunement definitions
export const ATTUNEMENTS_DEF: Record<string, AttunementDef> = {
// ─── Enchanter (Right Hand) ─────────────────────────────────────────────────
// Unlocks the enchanting system - applying magical effects to equipment
// Primary mana: Transference (used to move/apply enchantments)
enchanter: {
id: 'enchanter',
name: 'Enchanter',
desc: 'Channel transference mana through your right hand to apply magical enchantments to equipment. The art of enchanting allows you to imbue items with spell effects, stat bonuses, and special properties.',
slot: 'rightHand',
icon: '✨',
color: '#1ABC9C', // Teal (transference color)
primaryManaType: 'transference',
rawManaRegen: 0.5,
conversionRate: 0.2, // Converts 0.2 raw mana to transference per hour
unlocked: true, // Starting attunement
capabilities: ['enchanting', 'disenchanting'],
skillCategories: ['enchant', 'effectResearch'],
},
// ─── Invoker (Chest/Heart) ───────────────────────────────────────────────────
// Enables forming pacts with spire guardians
// No primary mana - instead gains mana types from each pact signed
invoker: {
id: 'invoker',
name: 'Invoker',
desc: 'Open your heart to the guardians of the spire. Form pacts with defeated guardians to gain their elemental affinity and access to their unique powers. Each pact grants access to a new mana type.',
slot: 'chest',
icon: '💜',
color: '#9B59B6', // Purple
primaryManaType: undefined, // Invoker has no primary - gains from pacts
rawManaRegen: 0.3,
conversionRate: 0, // No automatic conversion - mana comes from pacts
unlocked: false, // Unlocked through gameplay
unlockCondition: 'Defeat your first guardian and choose the path of the Invoker',
capabilities: ['pacts', 'guardianPowers', 'elementalMastery'],
skillCategories: ['invocation', 'pact'],
},
// ─── Fabricator (Left Hand) ──────────────────────────────────────────────────
// Crafts earth golems and earthen gear
// Primary mana: Earth
// Later with fire mana -> metal mana, can craft metallic gear and golems
fabricator: {
id: 'fabricator',
name: 'Fabricator',
desc: 'Shape earth and metal through your left hand to craft golems and equipment. Start with earthen constructs, and unlock metalworking when you gain fire mana to create metal mana.',
slot: 'leftHand',
icon: '⚒️',
color: '#F4A261', // Earth color
primaryManaType: 'earth',
rawManaRegen: 0.4,
conversionRate: 0.25, // Converts 0.25 raw mana to earth per hour
unlocked: false, // Unlocked through gameplay
unlockCondition: 'Prove your worth as a crafter',
capabilities: ['golemCrafting', 'gearCrafting', 'earthShaping'],
skillCategories: ['fabrication', 'golemancy'],
},
};
// Helper function to get attunement by slot
export function getAttunementBySlot(slot: AttunementSlot): AttunementDef | undefined {
return Object.values(ATTUNEMENTS_DEF).find(a => a.slot === slot);
}
// Helper function to get all unlocked attunements for a player
export function getUnlockedAttunements(attunements: Record<string, { active: boolean; level: number; experience: number }>): AttunementDef[] {
return Object.entries(attunements)
.filter(([id, state]) => state.active || ATTUNEMENTS_DEF[id]?.unlocked)
.map(([id]) => ATTUNEMENTS_DEF[id])
.filter(Boolean);
}
// Helper function to calculate total raw mana regen from attunements (with level scaling)
export function getTotalAttunementRegen(attunements: Record<string, { active: boolean; level: number; experience: number }>): number {
return Object.entries(attunements)
.filter(([, state]) => state.active)
.reduce((total, [id, state]) => {
const def = ATTUNEMENTS_DEF[id];
if (!def) return total;
// Exponential scaling: base * (1.5 ^ (level - 1))
const levelMult = Math.pow(1.5, (state.level || 1) - 1);
return total + def.rawManaRegen * levelMult;
}, 0);
}
// Get conversion rate with level scaling
export function getAttunementConversionRate(attunementId: string, level: number): number {
const def = ATTUNEMENTS_DEF[attunementId];
if (!def || def.conversionRate <= 0) return 0;
// Exponential scaling: base * (1.5 ^ (level - 1))
return def.conversionRate * Math.pow(1.5, (level || 1) - 1);
}
// XP required for attunement level
export function getAttunementXPForLevel(level: number): number {
// New scaling:
// Level 2: 1000 XP
// Level 3: 2500 XP
// Level 4: 5000 XP
// Level 5: 10000 XP
// etc. (each level requires 2x the previous, starting from 1000)
if (level <= 1) return 0;
if (level === 2) return 1000;
// For level 3+: 1000 * 2.5^(level-2), but rounded nicely
return Math.floor(1000 * Math.pow(2, level - 2) * (level >= 3 ? 1.25 : 1));
}
// Calculate XP gained from enchanting based on capacity used
export function calculateEnchantingXP(capacityUsed: number): number {
// 1 XP per 10 capacity used, floored, minimum 1
return Math.max(1, Math.floor(capacityUsed / 10));
}
// Max attunement level
export const MAX_ATTUNEMENT_LEVEL = 10;
// Helper function to get mana types from active attunements and pacts
export function getAttunementManaTypes(
attunements: Record<string, { active: boolean; level: number; experience: number }>,
signedPacts: number[]
): string[] {
const manaTypes: string[] = [];
// Add primary mana types from active attunements
Object.entries(attunements)
.filter(([, state]) => state.active)
.forEach(([id]) => {
const def = ATTUNEMENTS_DEF[id];
if (def?.primaryManaType) {
manaTypes.push(def.primaryManaType);
}
});
// Invoker gains mana types from signed pacts
if (attunements.invoker?.active && signedPacts.length > 0) {
// Import GUARDIANS would be circular, so this is handled in the store
// For now, just mark that invoker provides pact-based mana
manaTypes.push('pactElements');
}
return [...new Set(manaTypes)]; // Remove duplicates
}
// Get skill categories available to player based on active attunements
export function getAvailableSkillCategories(
attunements: Record<string, { active: boolean; level: number; experience: number }>
): string[] {
const categories = new Set<string>();
// Always available categories
categories.add('mana');
categories.add('study');
categories.add('research');
categories.add('ascension');
// Add categories from active attunements
Object.entries(attunements)
.filter(([, state]) => state.active)
.forEach(([id]) => {
const def = ATTUNEMENTS_DEF[id];
if (def?.skillCategories) {
def.skillCategories.forEach(cat => categories.add(cat));
}
});
return Array.from(categories);
}

View File

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

View File

@@ -0,0 +1,603 @@
// ─── Enchantment Effects Catalogue ────────────────────────────────────────────────
// All available enchantment effects that can be applied to equipment
import type { EquipmentCategory } from './equipment'
// Helper to define allowed equipment categories for each effect type
const ALL_CASTER: EquipmentCategory[] = ['caster']
const CASTER_AND_HANDS: EquipmentCategory[] = ['caster', 'hands']
const BODY_AND_SHIELD: EquipmentCategory[] = ['body', 'shield']
const CASTER_CATALYST_ACCESSORY: EquipmentCategory[] = ['caster', 'catalyst', 'accessory']
const MANA_EQUIPMENT: EquipmentCategory[] = ['caster', 'catalyst', 'head', 'body', 'accessory']
const UTILITY_EQUIPMENT: EquipmentCategory[] = ['caster', 'catalyst', 'head', 'body', 'hands', 'feet', 'accessory']
const ALL_EQUIPMENT: EquipmentCategory[] = ['caster', 'shield', 'catalyst', 'head', 'body', 'hands', 'feet', 'accessory']
export type EnchantmentEffectCategory = 'spell' | 'mana' | 'combat' | 'elemental' | 'defense' | 'utility' | 'special'
export interface EnchantmentEffectDef {
id: string;
name: string;
description: string;
category: EnchantmentEffectCategory;
baseCapacityCost: number;
maxStacks: number;
allowedEquipmentCategories: EquipmentCategory[];
effect: {
type: 'spell' | 'bonus' | 'multiplier' | 'special';
spellId?: string;
stat?: string;
value?: number;
specialId?: string;
};
}
export const ENCHANTMENT_EFFECTS: Record<string, EnchantmentEffectDef> = {
// ═══════════════════════════════════════════════════════════════════════════
// SPELL EFFECTS - Only for CASTER equipment (staves, wands, rods, orbs)
// ═══════════════════════════════════════════════════════════════════════════
// Tier 0 - Basic Spells
spell_manaBolt: {
id: 'spell_manaBolt',
name: 'Mana Bolt',
description: 'Grants the ability to cast Mana Bolt (5 base damage, raw mana cost)',
category: 'spell',
baseCapacityCost: 50,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'manaBolt' }
},
spell_manaStrike: {
id: 'spell_manaStrike',
name: 'Mana Strike',
description: 'Grants the ability to cast Mana Strike (8 base damage, raw mana cost)',
category: 'spell',
baseCapacityCost: 40,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'manaStrike' }
},
// Tier 1 - Basic Elemental Spells
spell_fireball: {
id: 'spell_fireball',
name: 'Fireball',
description: 'Grants the ability to cast Fireball (15 fire damage)',
category: 'spell',
baseCapacityCost: 80,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'fireball' }
},
spell_emberShot: {
id: 'spell_emberShot',
name: 'Ember Shot',
description: 'Grants the ability to cast Ember Shot (10 fire damage, fast cast)',
category: 'spell',
baseCapacityCost: 60,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'emberShot' }
},
spell_waterJet: {
id: 'spell_waterJet',
name: 'Water Jet',
description: 'Grants the ability to cast Water Jet (12 water damage)',
category: 'spell',
baseCapacityCost: 70,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'waterJet' }
},
spell_iceShard: {
id: 'spell_iceShard',
name: 'Ice Shard',
description: 'Grants the ability to cast Ice Shard (14 water damage)',
category: 'spell',
baseCapacityCost: 75,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'iceShard' }
},
spell_gust: {
id: 'spell_gust',
name: 'Gust',
description: 'Grants the ability to cast Gust (10 air damage, fast cast)',
category: 'spell',
baseCapacityCost: 60,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'gust' }
},
spell_stoneBullet: {
id: 'spell_stoneBullet',
name: 'Stone Bullet',
description: 'Grants the ability to cast Stone Bullet (16 earth damage)',
category: 'spell',
baseCapacityCost: 80,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'stoneBullet' }
},
spell_lightLance: {
id: 'spell_lightLance',
name: 'Light Lance',
description: 'Grants the ability to cast Light Lance (18 light damage)',
category: 'spell',
baseCapacityCost: 95,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'lightLance' }
},
spell_shadowBolt: {
id: 'spell_shadowBolt',
name: 'Shadow Bolt',
description: 'Grants the ability to cast Shadow Bolt (16 dark damage)',
category: 'spell',
baseCapacityCost: 95,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'shadowBolt' }
},
spell_drain: {
id: 'spell_drain',
name: 'Drain',
description: 'Grants the ability to cast Drain (10 death damage, 20% lifesteal)',
category: 'spell',
baseCapacityCost: 85,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'drain' }
},
spell_lifeTap: {
id: 'spell_lifeTap',
name: 'Life Tap',
description: 'Grants the ability to cast Life Tap (8 life damage, 30% lifesteal)',
category: 'spell',
baseCapacityCost: 70,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'lifeTap' }
},
// Tier 2 - Advanced Spells
spell_inferno: {
id: 'spell_inferno',
name: 'Inferno',
description: 'Grants the ability to cast Inferno (60 fire damage)',
category: 'spell',
baseCapacityCost: 180,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'inferno' }
},
spell_tidalWave: {
id: 'spell_tidalWave',
name: 'Tidal Wave',
description: 'Grants the ability to cast Tidal Wave (55 water damage)',
category: 'spell',
baseCapacityCost: 175,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'tidalWave' }
},
spell_hurricane: {
id: 'spell_hurricane',
name: 'Hurricane',
description: 'Grants the ability to cast Hurricane (50 air damage)',
category: 'spell',
baseCapacityCost: 170,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'hurricane' }
},
spell_earthquake: {
id: 'spell_earthquake',
name: 'Earthquake',
description: 'Grants the ability to cast Earthquake (70 earth damage)',
category: 'spell',
baseCapacityCost: 200,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'earthquake' }
},
spell_solarFlare: {
id: 'spell_solarFlare',
name: 'Solar Flare',
description: 'Grants the ability to cast Solar Flare (65 light damage)',
category: 'spell',
baseCapacityCost: 190,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'solarFlare' }
},
spell_voidRift: {
id: 'spell_voidRift',
name: 'Void Rift',
description: 'Grants the ability to cast Void Rift (55 dark damage)',
category: 'spell',
baseCapacityCost: 175,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'voidRift' }
},
// Additional Tier 1 Spells
spell_windSlash: {
id: 'spell_windSlash',
name: 'Wind Slash',
description: 'Grants the ability to cast Wind Slash (12 air damage)',
category: 'spell',
baseCapacityCost: 72,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'windSlash' }
},
spell_rockSpike: {
id: 'spell_rockSpike',
name: 'Rock Spike',
description: 'Grants the ability to cast Rock Spike (18 earth damage)',
category: 'spell',
baseCapacityCost: 88,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'rockSpike' }
},
spell_radiance: {
id: 'spell_radiance',
name: 'Radiance',
description: 'Grants the ability to cast Radiance (14 light damage)',
category: 'spell',
baseCapacityCost: 80,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'radiance' }
},
spell_darkPulse: {
id: 'spell_darkPulse',
name: 'Dark Pulse',
description: 'Grants the ability to cast Dark Pulse (12 dark damage)',
category: 'spell',
baseCapacityCost: 68,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'darkPulse' }
},
// Additional Tier 2 Spells
spell_flameWave: {
id: 'spell_flameWave',
name: 'Flame Wave',
description: 'Grants the ability to cast Flame Wave (45 fire damage)',
category: 'spell',
baseCapacityCost: 165,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'flameWave' }
},
spell_iceStorm: {
id: 'spell_iceStorm',
name: 'Ice Storm',
description: 'Grants the ability to cast Ice Storm (50 water damage)',
category: 'spell',
baseCapacityCost: 170,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'iceStorm' }
},
spell_windBlade: {
id: 'spell_windBlade',
name: 'Wind Blade',
description: 'Grants the ability to cast Wind Blade (40 air damage)',
category: 'spell',
baseCapacityCost: 155,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'windBlade' }
},
spell_stoneBarrage: {
id: 'spell_stoneBarrage',
name: 'Stone Barrage',
description: 'Grants the ability to cast Stone Barrage (55 earth damage)',
category: 'spell',
baseCapacityCost: 175,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'stoneBarrage' }
},
spell_divineSmite: {
id: 'spell_divineSmite',
name: 'Divine Smite',
description: 'Grants the ability to cast Divine Smite (55 light damage)',
category: 'spell',
baseCapacityCost: 175,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'divineSmite' }
},
spell_shadowStorm: {
id: 'spell_shadowStorm',
name: 'Shadow Storm',
description: 'Grants the ability to cast Shadow Storm (48 dark damage)',
category: 'spell',
baseCapacityCost: 168,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'shadowStorm' }
},
// Tier 3 - Master Spells
spell_pyroclasm: {
id: 'spell_pyroclasm',
name: 'Pyroclasm',
description: 'Grants the ability to cast Pyroclasm (250 fire damage)',
category: 'spell',
baseCapacityCost: 400,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'pyroclasm' }
},
spell_tsunami: {
id: 'spell_tsunami',
name: 'Tsunami',
description: 'Grants the ability to cast Tsunami (220 water damage)',
category: 'spell',
baseCapacityCost: 380,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'tsunami' }
},
spell_meteorStrike: {
id: 'spell_meteorStrike',
name: 'Meteor Strike',
description: 'Grants the ability to cast Meteor Strike (280 earth damage)',
category: 'spell',
baseCapacityCost: 420,
maxStacks: 1,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'spell', spellId: 'meteorStrike' }
},
// ═══════════════════════════════════════════════════════════════════════════
// MANA EFFECTS - Boost mana capacity and regeneration
// ═══════════════════════════════════════════════════════════════════════════
mana_cap_50: {
id: 'mana_cap_50',
name: 'Mana Reserve',
description: '+50 maximum mana',
category: 'mana',
baseCapacityCost: 20,
maxStacks: 3,
allowedEquipmentCategories: MANA_EQUIPMENT,
effect: { type: 'bonus', stat: 'maxMana', value: 50 }
},
mana_cap_100: {
id: 'mana_cap_100',
name: 'Mana Reservoir',
description: '+100 maximum mana',
category: 'mana',
baseCapacityCost: 35,
maxStacks: 3,
allowedEquipmentCategories: MANA_EQUIPMENT,
effect: { type: 'bonus', stat: 'maxMana', value: 100 }
},
mana_regen_1: {
id: 'mana_regen_1',
name: 'Trickle',
description: '+1 mana per hour regeneration',
category: 'mana',
baseCapacityCost: 15,
maxStacks: 5,
allowedEquipmentCategories: MANA_EQUIPMENT,
effect: { type: 'bonus', stat: 'regen', value: 1 }
},
mana_regen_2: {
id: 'mana_regen_2',
name: 'Stream',
description: '+2 mana per hour regeneration',
category: 'mana',
baseCapacityCost: 28,
maxStacks: 4,
allowedEquipmentCategories: MANA_EQUIPMENT,
effect: { type: 'bonus', stat: 'regen', value: 2 }
},
mana_regen_5: {
id: 'mana_regen_5',
name: 'River',
description: '+5 mana per hour regeneration',
category: 'mana',
baseCapacityCost: 50,
maxStacks: 3,
allowedEquipmentCategories: MANA_EQUIPMENT,
effect: { type: 'bonus', stat: 'regen', value: 5 }
},
click_mana_1: {
id: 'click_mana_1',
name: 'Mana Tap',
description: '+1 mana per click',
category: 'mana',
baseCapacityCost: 20,
maxStacks: 5,
allowedEquipmentCategories: MANA_EQUIPMENT,
effect: { type: 'bonus', stat: 'clickMana', value: 1 }
},
click_mana_3: {
id: 'click_mana_3',
name: 'Mana Surge',
description: '+3 mana per click',
category: 'mana',
baseCapacityCost: 35,
maxStacks: 3,
allowedEquipmentCategories: MANA_EQUIPMENT,
effect: { type: 'bonus', stat: 'clickMana', value: 3 }
},
// ═══════════════════════════════════════════════════════════════════════════
// COMBAT EFFECTS - Damage and attack enhancements
// ═══════════════════════════════════════════════════════════════════════════
damage_5: {
id: 'damage_5',
name: 'Minor Power',
description: '+5 base damage',
category: 'combat',
baseCapacityCost: 15,
maxStacks: 5,
allowedEquipmentCategories: CASTER_AND_HANDS,
effect: { type: 'bonus', stat: 'baseDamage', value: 5 }
},
damage_10: {
id: 'damage_10',
name: 'Moderate Power',
description: '+10 base damage',
category: 'combat',
baseCapacityCost: 28,
maxStacks: 4,
allowedEquipmentCategories: CASTER_AND_HANDS,
effect: { type: 'bonus', stat: 'baseDamage', value: 10 }
},
damage_pct_10: {
id: 'damage_pct_10',
name: 'Amplification',
description: '+10% damage',
category: 'combat',
baseCapacityCost: 30,
maxStacks: 3,
allowedEquipmentCategories: CASTER_AND_HANDS,
effect: { type: 'multiplier', stat: 'baseDamage', value: 1.10 }
},
crit_5: {
id: 'crit_5',
name: 'Sharp Edge',
description: '+5% critical hit chance',
category: 'combat',
baseCapacityCost: 20,
maxStacks: 4,
allowedEquipmentCategories: CASTER_AND_HANDS,
effect: { type: 'bonus', stat: 'critChance', value: 0.05 }
},
attack_speed_10: {
id: 'attack_speed_10',
name: 'Swift Casting',
description: '+10% attack speed',
category: 'combat',
baseCapacityCost: 22,
maxStacks: 4,
allowedEquipmentCategories: CASTER_AND_HANDS,
effect: { type: 'multiplier', stat: 'attackSpeed', value: 1.10 }
},
// ═══════════════════════════════════════════════════════════════════════════
// UTILITY EFFECTS - Study speed, insight, meditation
// ═══════════════════════════════════════════════════════════════════════════
meditate_10: {
id: 'meditate_10',
name: 'Meditative Focus',
description: '+10% meditation efficiency',
category: 'utility',
baseCapacityCost: 18,
maxStacks: 5,
allowedEquipmentCategories: ['head', 'body', 'accessory'],
effect: { type: 'multiplier', stat: 'meditationEfficiency', value: 1.10 }
},
study_10: {
id: 'study_10',
name: 'Quick Study',
description: '+10% study speed',
category: 'utility',
baseCapacityCost: 22,
maxStacks: 4,
allowedEquipmentCategories: UTILITY_EQUIPMENT,
effect: { type: 'multiplier', stat: 'studySpeed', value: 1.10 }
},
insight_5: {
id: 'insight_5',
name: 'Insightful',
description: '+5% insight gain',
category: 'utility',
baseCapacityCost: 25,
maxStacks: 4,
allowedEquipmentCategories: ['head', 'accessory'],
effect: { type: 'multiplier', stat: 'insightGain', value: 1.05 }
},
// ═══════════════════════════════════════════════════════════════════════════
// SPECIAL EFFECTS - Unique and powerful effects
// ═══════════════════════════════════════════════════════════════════════════
spell_echo_10: {
id: 'spell_echo_10',
name: 'Echo Chamber',
description: '10% chance to cast a spell twice',
category: 'special',
baseCapacityCost: 60,
maxStacks: 2,
allowedEquipmentCategories: ALL_CASTER,
effect: { type: 'special', specialId: 'spellEcho10' }
},
lifesteal_5: {
id: 'lifesteal_5',
name: 'Siphoning',
description: '5% of damage dealt is returned as mana',
category: 'special',
baseCapacityCost: 45,
maxStacks: 2,
allowedEquipmentCategories: CASTER_AND_HANDS,
effect: { type: 'special', specialId: 'lifesteal5' }
},
guardian_dmg_10: {
id: 'guardian_dmg_10',
name: 'Bane',
description: '+10% damage to guardians',
category: 'special',
baseCapacityCost: 35,
maxStacks: 3,
allowedEquipmentCategories: CASTER_CATALYST_ACCESSORY,
effect: { type: 'multiplier', stat: 'guardianDamage', value: 1.10 }
},
overpower_80: {
id: 'overpower_80',
name: 'Overpower',
description: '+50% damage when mana above 80%',
category: 'special',
baseCapacityCost: 55,
maxStacks: 1,
allowedEquipmentCategories: CASTER_AND_HANDS,
effect: { type: 'special', specialId: 'overpower' }
},
};
// ─── Helper Functions ────────────────────────────────────────────────────────────
export function getEnchantmentEffect(id: string): EnchantmentEffectDef | undefined {
return ENCHANTMENT_EFFECTS[id];
}
export function getEffectsForEquipment(equipmentCategory: EquipmentCategory): EnchantmentEffectDef[] {
return Object.values(ENCHANTMENT_EFFECTS).filter(effect =>
effect.allowedEquipmentCategories.includes(equipmentCategory)
);
}
export function canApplyEffect(effectId: string, equipmentCategory: EquipmentCategory): boolean {
const effect = ENCHANTMENT_EFFECTS[effectId];
if (!effect) return false;
return effect.allowedEquipmentCategories.includes(equipmentCategory);
}
export function calculateEffectCapacityCost(effectId: string, stacks: number, efficiencyBonus: number = 0): number {
const effect = ENCHANTMENT_EFFECTS[effectId];
if (!effect) return 0;
let totalCost = 0;
for (let i = 0; i < stacks; i++) {
// Each additional stack costs 20% more
const stackMultiplier = 1 + (i * 0.2);
totalCost += effect.baseCapacityCost * stackMultiplier;
}
// Apply efficiency bonus (reduces cost)
return Math.floor(totalCost * (1 - efficiencyBonus));
}

411
src/lib/game/data/equipment.ts Executable file
View File

@@ -0,0 +1,411 @@
// ─── Equipment Types ─────────────────────────────────────────────────────────
export type EquipmentSlot = 'mainHand' | 'offHand' | 'head' | 'body' | 'hands' | 'feet' | 'accessory1' | 'accessory2';
export type EquipmentCategory = 'caster' | 'shield' | 'catalyst' | 'head' | 'body' | 'hands' | 'feet' | 'accessory';
// All equipment slots in order
export const EQUIPMENT_SLOTS: EquipmentSlot[] = ['mainHand', 'offHand', 'head', 'body', 'hands', 'feet', 'accessory1', 'accessory2'];
export interface EquipmentType {
id: string;
name: string;
category: EquipmentCategory;
slot: EquipmentSlot;
baseCapacity: number;
description: string;
}
// ─── Equipment Types Definition ─────────────────────────────────────────────
export const EQUIPMENT_TYPES: Record<string, EquipmentType> = {
// ─── Main Hand - Casters ─────────────────────────────────────────────────
basicStaff: {
id: 'basicStaff',
name: 'Basic Staff',
category: 'caster',
slot: 'mainHand',
baseCapacity: 50,
description: 'A simple wooden staff, basic but reliable for channeling mana.',
},
apprenticeWand: {
id: 'apprenticeWand',
name: 'Apprentice Wand',
category: 'caster',
slot: 'mainHand',
baseCapacity: 35,
description: 'A lightweight wand favored by apprentices. Lower capacity but faster to prepare.',
},
oakStaff: {
id: 'oakStaff',
name: 'Oak Staff',
category: 'caster',
slot: 'mainHand',
baseCapacity: 65,
description: 'A sturdy oak staff with decent mana capacity.',
},
crystalWand: {
id: 'crystalWand',
name: 'Crystal Wand',
category: 'caster',
slot: 'mainHand',
baseCapacity: 45,
description: 'A wand tipped with a small crystal. Excellent for elemental enchantments.',
},
arcanistStaff: {
id: 'arcanistStaff',
name: 'Arcanist Staff',
category: 'caster',
slot: 'mainHand',
baseCapacity: 80,
description: 'A staff designed for advanced spellcasters. High capacity for complex enchantments.',
},
battlestaff: {
id: 'battlestaff',
name: 'Battlestaff',
category: 'caster',
slot: 'mainHand',
baseCapacity: 70,
description: 'A reinforced staff suitable for both casting and combat.',
},
// ─── Main Hand - Catalysts ────────────────────────────────────────────────
basicCatalyst: {
id: 'basicCatalyst',
name: 'Basic Catalyst',
category: 'catalyst',
slot: 'mainHand',
baseCapacity: 40,
description: 'A simple catalyst for amplifying magical effects.',
},
fireCatalyst: {
id: 'fireCatalyst',
name: 'Fire Catalyst',
category: 'catalyst',
slot: 'mainHand',
baseCapacity: 55,
description: 'A catalyst attuned to fire magic. Enhances fire enchantments.',
},
voidCatalyst: {
id: 'voidCatalyst',
name: 'Void Catalyst',
category: 'catalyst',
slot: 'mainHand',
baseCapacity: 75,
description: 'A rare catalyst touched by void energy. High capacity but volatile.',
},
// ─── Off Hand - Shields ───────────────────────────────────────────────────
basicShield: {
id: 'basicShield',
name: 'Basic Shield',
category: 'shield',
slot: 'offHand',
baseCapacity: 40,
description: 'A simple wooden shield. Provides basic protection.',
},
reinforcedShield: {
id: 'reinforcedShield',
name: 'Reinforced Shield',
category: 'shield',
slot: 'offHand',
baseCapacity: 55,
description: 'A metal-reinforced shield with enhanced durability and capacity.',
},
runicShield: {
id: 'runicShield',
name: 'Runic Shield',
category: 'shield',
slot: 'offHand',
baseCapacity: 70,
description: 'A shield engraved with protective runes. Excellent for defensive enchantments.',
},
manaShield: {
id: 'manaShield',
name: 'Mana Shield',
category: 'shield',
slot: 'offHand',
baseCapacity: 60,
description: 'A crystalline shield that can store and reflect mana.',
},
// ─── Head ─────────────────────────────────────────────────────────────────
clothHood: {
id: 'clothHood',
name: 'Cloth Hood',
category: 'head',
slot: 'head',
baseCapacity: 25,
description: 'A simple cloth hood. Minimal protection but comfortable.',
},
apprenticeCap: {
id: 'apprenticeCap',
name: 'Apprentice Cap',
category: 'head',
slot: 'head',
baseCapacity: 30,
description: 'The traditional cap of magic apprentices.',
},
wizardHat: {
id: 'wizardHat',
name: 'Wizard Hat',
category: 'head',
slot: 'head',
baseCapacity: 45,
description: 'A classic pointed wizard hat. Decent capacity for headwear.',
},
arcanistCirclet: {
id: 'arcanistCirclet',
name: 'Arcanist Circlet',
category: 'head',
slot: 'head',
baseCapacity: 40,
description: 'A silver circlet worn by accomplished arcanists.',
},
battleHelm: {
id: 'battleHelm',
name: 'Battle Helm',
category: 'head',
slot: 'head',
baseCapacity: 50,
description: 'A sturdy helm for battle mages.',
},
// ─── Body ────────────────────────────────────────────────────────────────
civilianShirt: {
id: 'civilianShirt',
name: 'Civilian Shirt',
category: 'body',
slot: 'body',
baseCapacity: 30,
description: 'A plain shirt with minimal magical properties.',
},
apprenticeRobe: {
id: 'apprenticeRobe',
name: 'Apprentice Robe',
category: 'body',
slot: 'body',
baseCapacity: 45,
description: 'The standard robe for magic apprentices.',
},
scholarRobe: {
id: 'scholarRobe',
name: 'Scholar Robe',
category: 'body',
slot: 'body',
baseCapacity: 55,
description: 'A robe worn by scholars and researchers.',
},
battleRobe: {
id: 'battleRobe',
name: 'Battle Robe',
category: 'body',
slot: 'body',
baseCapacity: 65,
description: 'A reinforced robe designed for combat mages.',
},
arcanistRobe: {
id: 'arcanistRobe',
name: 'Arcanist Robe',
category: 'body',
slot: 'body',
baseCapacity: 80,
description: 'An ornate robe for master arcanists. High capacity for body armor.',
},
// ─── Hands ───────────────────────────────────────────────────────────────
civilianGloves: {
id: 'civilianGloves',
name: 'Civilian Gloves',
category: 'hands',
slot: 'hands',
baseCapacity: 20,
description: 'Simple cloth gloves. Minimal magical capacity.',
},
apprenticeGloves: {
id: 'apprenticeGloves',
name: 'Apprentice Gloves',
category: 'hands',
slot: 'hands',
baseCapacity: 30,
description: 'Basic gloves for handling magical components.',
},
spellweaveGloves: {
id: 'spellweaveGloves',
name: 'Spellweave Gloves',
category: 'hands',
slot: 'hands',
baseCapacity: 40,
description: 'Gloves woven with mana-conductive threads.',
},
combatGauntlets: {
id: 'combatGauntlets',
name: 'Combat Gauntlets',
category: 'hands',
slot: 'hands',
baseCapacity: 35,
description: 'Armored gauntlets for battle mages.',
},
// ─── Feet ────────────────────────────────────────────────────────────────
civilianShoes: {
id: 'civilianShoes',
name: 'Civilian Shoes',
category: 'feet',
slot: 'feet',
baseCapacity: 15,
description: 'Simple leather shoes. No special properties.',
},
apprenticeBoots: {
id: 'apprenticeBoots',
name: 'Apprentice Boots',
category: 'feet',
slot: 'feet',
baseCapacity: 25,
description: 'Basic boots for magic students.',
},
travelerBoots: {
id: 'travelerBoots',
name: 'Traveler Boots',
category: 'feet',
slot: 'feet',
baseCapacity: 30,
description: 'Comfortable boots for long journeys.',
},
battleBoots: {
id: 'battleBoots',
name: 'Battle Boots',
category: 'feet',
slot: 'feet',
baseCapacity: 35,
description: 'Sturdy boots for combat situations.',
},
// ─── Accessories ────────────────────────────────────────────────────────
copperRing: {
id: 'copperRing',
name: 'Copper Ring',
category: 'accessory',
slot: 'accessory1',
baseCapacity: 15,
description: 'A simple copper ring. Basic capacity for accessories.',
},
silverRing: {
id: 'silverRing',
name: 'Silver Ring',
category: 'accessory',
slot: 'accessory1',
baseCapacity: 25,
description: 'A silver ring with decent magical conductivity.',
},
goldRing: {
id: 'goldRing',
name: 'Gold Ring',
category: 'accessory',
slot: 'accessory1',
baseCapacity: 35,
description: 'A gold ring with excellent magical properties.',
},
signetRing: {
id: 'signetRing',
name: 'Signet Ring',
category: 'accessory',
slot: 'accessory1',
baseCapacity: 30,
description: 'A ring bearing a magical sigil.',
},
copperAmulet: {
id: 'copperAmulet',
name: 'Copper Amulet',
category: 'accessory',
slot: 'accessory1',
baseCapacity: 20,
description: 'A simple copper amulet on a leather cord.',
},
silverAmulet: {
id: 'silverAmulet',
name: 'Silver Amulet',
category: 'accessory',
slot: 'accessory1',
baseCapacity: 30,
description: 'A silver amulet with a small gem.',
},
crystalPendant: {
id: 'crystalPendant',
name: 'Crystal Pendant',
category: 'accessory',
slot: 'accessory1',
baseCapacity: 45,
description: 'A pendant with a mana-infused crystal.',
},
manaBrooch: {
id: 'manaBrooch',
name: 'Mana Brooch',
category: 'accessory',
slot: 'accessory1',
baseCapacity: 40,
description: 'A decorative brooch that can hold enchantments.',
},
arcanistPendant: {
id: 'arcanistPendant',
name: 'Arcanist Pendant',
category: 'accessory',
slot: 'accessory1',
baseCapacity: 55,
description: 'A powerful pendant worn by master arcanists.',
},
voidTouchedRing: {
id: 'voidTouchedRing',
name: 'Void-Touched Ring',
category: 'accessory',
slot: 'accessory1',
baseCapacity: 50,
description: 'A ring corrupted by void energy. High capacity but risky.',
},
};
// ─── Helper Functions ─────────────────────────────────────────────────────────
export function getEquipmentType(id: string): EquipmentType | undefined {
return EQUIPMENT_TYPES[id];
}
export function getEquipmentByCategory(category: EquipmentCategory): EquipmentType[] {
return Object.values(EQUIPMENT_TYPES).filter(e => e.category === category);
}
export function getEquipmentBySlot(slot: EquipmentSlot): EquipmentType[] {
return Object.values(EQUIPMENT_TYPES).filter(e => e.slot === slot);
}
export function getAllEquipmentTypes(): EquipmentType[] {
return Object.values(EQUIPMENT_TYPES);
}
// Get valid slots for a category
export function getValidSlotsForCategory(category: EquipmentCategory): EquipmentSlot[] {
switch (category) {
case 'caster':
case 'catalyst':
return ['mainHand'];
case 'shield':
return ['offHand'];
case 'head':
return ['head'];
case 'body':
return ['body'];
case 'hands':
return ['hands'];
case 'feet':
return ['feet'];
case 'accessory':
return ['accessory1', 'accessory2'];
default:
return [];
}
}
// Check if an equipment type can be equipped in a specific slot
export function canEquipInSlot(equipmentType: EquipmentType, slot: EquipmentSlot): boolean {
const validSlots = getValidSlotsForCategory(equipmentType.category);
return validSlots.includes(slot);
}

242
src/lib/game/data/loot-drops.ts Executable file
View File

@@ -0,0 +1,242 @@
// ─── Loot Drop Definitions ─────────────────────────────────────────────────────
import type { LootDrop } from '../types';
export const LOOT_DROPS: Record<string, LootDrop> = {
// ─── Materials (used for crafting) ───
manaCrystalDust: {
id: 'manaCrystalDust',
name: 'Mana Crystal Dust',
rarity: 'common',
type: 'material',
minFloor: 1,
dropChance: 0.15,
},
arcaneShard: {
id: 'arcaneShard',
name: 'Arcane Shard',
rarity: 'uncommon',
type: 'material',
minFloor: 10,
dropChance: 0.10,
},
elementalCore: {
id: 'elementalCore',
name: 'Elemental Core',
rarity: 'rare',
type: 'material',
minFloor: 25,
dropChance: 0.08,
},
voidEssence: {
id: 'voidEssence',
name: 'Void Essence',
rarity: 'epic',
type: 'material',
minFloor: 50,
dropChance: 0.05,
guardianOnly: true,
},
celestialFragment: {
id: 'celestialFragment',
name: 'Celestial Fragment',
rarity: 'legendary',
type: 'material',
minFloor: 75,
dropChance: 0.02,
guardianOnly: true,
},
// ─── Elemental Essence (grants elemental mana) ───
fireEssenceDrop: {
id: 'fireEssenceDrop',
name: 'Fire Essence',
rarity: 'uncommon',
type: 'essence',
minFloor: 5,
dropChance: 0.12,
amount: { min: 5, max: 15 },
},
waterEssenceDrop: {
id: 'waterEssenceDrop',
name: 'Water Essence',
rarity: 'uncommon',
type: 'essence',
minFloor: 5,
dropChance: 0.12,
amount: { min: 5, max: 15 },
},
airEssenceDrop: {
id: 'airEssenceDrop',
name: 'Air Essence',
rarity: 'uncommon',
type: 'essence',
minFloor: 5,
dropChance: 0.12,
amount: { min: 5, max: 15 },
},
earthEssenceDrop: {
id: 'earthEssenceDrop',
name: 'Earth Essence',
rarity: 'uncommon',
type: 'essence',
minFloor: 5,
dropChance: 0.12,
amount: { min: 5, max: 15 },
},
lightEssenceDrop: {
id: 'lightEssenceDrop',
name: 'Light Essence',
rarity: 'rare',
type: 'essence',
minFloor: 20,
dropChance: 0.08,
amount: { min: 3, max: 10 },
},
darkEssenceDrop: {
id: 'darkEssenceDrop',
name: 'Dark Essence',
rarity: 'rare',
type: 'essence',
minFloor: 20,
dropChance: 0.08,
amount: { min: 3, max: 10 },
},
lifeEssenceDrop: {
id: 'lifeEssenceDrop',
name: 'Life Essence',
rarity: 'epic',
type: 'essence',
minFloor: 40,
dropChance: 0.05,
amount: { min: 2, max: 8 },
},
deathEssenceDrop: {
id: 'deathEssenceDrop',
name: 'Death Essence',
rarity: 'epic',
type: 'essence',
minFloor: 40,
dropChance: 0.05,
amount: { min: 2, max: 8 },
},
// ─── Raw Mana Drops ───
manaOrb: {
id: 'manaOrb',
name: 'Mana Orb',
rarity: 'common',
type: 'gold', // Uses gold type but gives raw mana
minFloor: 1,
dropChance: 0.20,
amount: { min: 10, max: 50 },
},
greaterManaOrb: {
id: 'greaterManaOrb',
name: 'Greater Mana Orb',
rarity: 'uncommon',
type: 'gold',
minFloor: 15,
dropChance: 0.10,
amount: { min: 50, max: 150 },
},
supremeManaOrb: {
id: 'supremeManaOrb',
name: 'Supreme Mana Orb',
rarity: 'rare',
type: 'gold',
minFloor: 35,
dropChance: 0.05,
amount: { min: 100, max: 500 },
},
// ─── Equipment Blueprints ───
staffBlueprint: {
id: 'staffBlueprint',
name: 'Staff Blueprint',
rarity: 'uncommon',
type: 'blueprint',
minFloor: 10,
dropChance: 0.03,
},
wandBlueprint: {
id: 'wandBlueprint',
name: 'Wand Blueprint',
rarity: 'rare',
type: 'blueprint',
minFloor: 20,
dropChance: 0.02,
},
robeBlueprint: {
id: 'robeBlueprint',
name: 'Mage Robe Blueprint',
rarity: 'rare',
type: 'blueprint',
minFloor: 25,
dropChance: 0.02,
},
artifactBlueprint: {
id: 'artifactBlueprint',
name: 'Artifact Blueprint',
rarity: 'legendary',
type: 'blueprint',
minFloor: 60,
dropChance: 0.01,
guardianOnly: true,
},
};
// Rarity colors for UI
export const RARITY_COLORS: Record<string, { color: string; glow: string }> = {
common: { color: '#9CA3AF', glow: '#9CA3AF40' },
uncommon: { color: '#22C55E', glow: '#22C55E40' },
rare: { color: '#3B82F6', glow: '#3B82F640' },
epic: { color: '#A855F7', glow: '#A855F740' },
legendary: { color: '#F59E0B', glow: '#F59E0B60' },
};
// Get loot drops available at a given floor
export function getAvailableDrops(floor: number, isGuardian: boolean): LootDrop[] {
return Object.values(LOOT_DROPS).filter(drop => {
if (drop.minFloor > floor) return false;
if (drop.guardianOnly && !isGuardian) return false;
return true;
});
}
// Roll for loot drops
export function rollLootDrops(
floor: number,
isGuardian: boolean,
luckBonus: number = 0
): Array<{ drop: LootDrop; amount: number }> {
const available = getAvailableDrops(floor, isGuardian);
const drops: Array<{ drop: LootDrop; amount: number }> = [];
for (const drop of available) {
// Calculate adjusted drop chance
let chance = drop.dropChance;
chance *= (1 + luckBonus); // Apply luck bonus
// Guardian floors have 2x drop rate
if (isGuardian) chance *= 2;
// Cap at 50% for any single drop
chance = Math.min(0.5, chance);
if (Math.random() < chance) {
let amount = 1;
// For gold/essence types, roll amount
if (drop.amount) {
amount = Math.floor(
Math.random() * (drop.amount.max - drop.amount.min + 1) + drop.amount.min
);
}
drops.push({ drop, amount });
}
}
return drops;
}

215
src/lib/game/effects.ts Executable file
View File

@@ -0,0 +1,215 @@
// ─── Unified Effect System ─────────────────────────────────────────────────────────
// This module consolidates ALL effect sources into a single computation:
// - Skill upgrade effects (from milestone upgrades)
// - Equipment enchantment effects (from enchanted gear)
// - Direct skill bonuses (from skill levels)
import type { GameState, EquipmentInstance } from './types';
import { ENCHANTMENT_EFFECTS } from './data/enchantment-effects';
import { computeEffects, hasSpecial, SPECIAL_EFFECTS, type ComputedEffects } from './upgrade-effects';
// Re-export for convenience
export { computeEffects, hasSpecial, SPECIAL_EFFECTS, type ComputedEffects };
// ─── Equipment Effect Computation ────────────────────────────────────────────────
/**
* Compute all effects from equipped enchantments
*/
export function computeEquipmentEffects(
equipmentInstances: Record<string, EquipmentInstance>,
equippedInstances: Record<string, string | null>
): {
bonuses: Record<string, number>;
multipliers: Record<string, number>;
specials: Set<string>;
} {
const bonuses: Record<string, number> = {};
const multipliers: Record<string, number> = {};
const specials = new Set<string>();
// Iterate through all equipped items
for (const instanceId of Object.values(equippedInstances)) {
if (!instanceId) continue;
const instance = equipmentInstances[instanceId];
if (!instance) continue;
// Process each enchantment on the item
for (const ench of instance.enchantments) {
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
if (!effectDef) continue;
const { effect } = effectDef;
if (effect.type === 'bonus' && effect.stat && effect.value) {
// Bonus effects add to the stat
bonuses[effect.stat] = (bonuses[effect.stat] || 0) + effect.value * ench.stacks;
} else if (effect.type === 'multiplier' && effect.stat && effect.value) {
// Multiplier effects multiply together
// For multipliers, we need to track them separately and apply as product
const key = effect.stat;
if (!multipliers[key]) {
multipliers[key] = 1;
}
// Each stack applies the multiplier
for (let i = 0; i < ench.stacks; i++) {
multipliers[key] *= effect.value;
}
} else if (effect.type === 'special' && effect.specialId) {
specials.add(effect.specialId);
}
}
}
return { bonuses, multipliers, specials };
}
// ─── Unified Computed Effects ────────────────────────────────────────────────────
export interface UnifiedEffects extends ComputedEffects {
// Equipment bonuses
equipmentBonuses: Record<string, number>;
equipmentMultipliers: Record<string, number>;
equipmentSpecials: Set<string>;
}
/**
* Compute all effects from all sources: skill upgrades + equipment enchantments
*/
export function computeAllEffects(
skillUpgrades: Record<string, string[]>,
skillTiers: Record<string, number>,
equipmentInstances: Record<string, EquipmentInstance>,
equippedInstances: Record<string, string | null>
): UnifiedEffects {
// Get skill upgrade effects
const upgradeEffects = computeEffects(skillUpgrades, skillTiers);
// Get equipment effects
const equipmentEffects = computeEquipmentEffects(equipmentInstances, equippedInstances);
// Merge the effects
const merged: UnifiedEffects = {
...upgradeEffects,
// Merge equipment bonuses with upgrade bonuses
maxManaBonus: upgradeEffects.maxManaBonus + (equipmentEffects.bonuses.maxMana || 0),
regenBonus: upgradeEffects.regenBonus + (equipmentEffects.bonuses.regen || 0),
clickManaBonus: upgradeEffects.clickManaBonus + (equipmentEffects.bonuses.clickMana || 0),
baseDamageBonus: upgradeEffects.baseDamageBonus + (equipmentEffects.bonuses.baseDamage || 0),
elementCapBonus: upgradeEffects.elementCapBonus + (equipmentEffects.bonuses.elementCap || 0),
// Merge equipment multipliers with upgrade multipliers
maxManaMultiplier: upgradeEffects.maxManaMultiplier * (equipmentEffects.multipliers.maxMana || 1),
regenMultiplier: upgradeEffects.regenMultiplier * (equipmentEffects.multipliers.regen || 1),
clickManaMultiplier: upgradeEffects.clickManaMultiplier * (equipmentEffects.multipliers.clickMana || 1),
baseDamageMultiplier: upgradeEffects.baseDamageMultiplier * (equipmentEffects.multipliers.baseDamage || 1),
attackSpeedMultiplier: upgradeEffects.attackSpeedMultiplier * (equipmentEffects.multipliers.attackSpeed || 1),
elementCapMultiplier: upgradeEffects.elementCapMultiplier * (equipmentEffects.multipliers.elementCap || 1),
// Merge specials
specials: new Set([...upgradeEffects.specials, ...equipmentEffects.specials]),
// Store equipment effects for reference
equipmentBonuses: equipmentEffects.bonuses,
equipmentMultipliers: equipmentEffects.multipliers,
equipmentSpecials: equipmentEffects.specials,
};
// Handle special stats that are equipment-only
if (equipmentEffects.bonuses.critChance) {
merged.critChanceBonus += equipmentEffects.bonuses.critChance;
}
if (equipmentEffects.bonuses.meditationEfficiency) {
// This is a multiplier in equipment, convert to additive for simplicity
// Equipment gives +10% per stack, so add it to the base
merged.meditationEfficiency *= (equipmentEffects.multipliers.meditationEfficiency || 1);
}
if (equipmentEffects.bonuses.studySpeed) {
merged.studySpeedMultiplier *= (equipmentEffects.multipliers.studySpeed || 1);
}
if (equipmentEffects.bonuses.insightGain) {
// Store separately - insight multiplier
(merged as any).insightGainMultiplier = (equipmentEffects.multipliers.insightGain || 1);
}
if (equipmentEffects.bonuses.guardianDamage) {
(merged as any).guardianDamageMultiplier = (equipmentEffects.multipliers.guardianDamage || 1);
}
return merged;
}
/**
* Helper to get unified effects from game state
*/
export function getUnifiedEffects(state: Pick<GameState, 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>): UnifiedEffects {
return computeAllEffects(
state.skillUpgrades || {},
state.skillTiers || {},
state.equipmentInstances,
state.equippedInstances
);
}
// ─── Stat Computation with All Effects ───────────────────────────────────────────
/**
* Compute max mana with all effect sources
*/
export function computeTotalMaxMana(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: UnifiedEffects
): number {
const pu = state.prestigeUpgrades;
const base =
100 +
(state.skills.manaWell || 0) * 100 +
(pu.manaWell || 0) * 500;
if (!effects) {
effects = getUnifiedEffects(state);
}
return Math.floor((base + effects.maxManaBonus) * effects.maxManaMultiplier);
}
/**
* Compute regen with all effect sources
*/
export function computeTotalRegen(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: UnifiedEffects
): number {
const pu = state.prestigeUpgrades;
const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1;
const base =
2 +
(state.skills.manaFlow || 0) * 1 +
(state.skills.manaSpring || 0) * 2 +
(pu.manaFlow || 0) * 0.5;
let regen = base * temporalBonus;
if (!effects) {
effects = getUnifiedEffects(state);
}
regen = (regen + effects.regenBonus + effects.permanentRegenBonus) * effects.regenMultiplier;
return regen;
}
/**
* Compute click mana with all effect sources
*/
export function computeTotalClickMana(
state: Pick<GameState, 'skills' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
effects?: UnifiedEffects
): number {
const base =
1 +
(state.skills.manaTap || 0) * 1 +
(state.skills.manaSurge || 0) * 3;
if (!effects) {
effects = getUnifiedEffects(state as any);
}
return Math.floor((base + effects.clickManaBonus) * effects.clickManaMultiplier);
}

46
src/lib/game/formatting.ts Executable file
View File

@@ -0,0 +1,46 @@
// ─── Shared Formatting Utilities ─────────────────────────────────────────────────
// Utility functions for consistent formatting across components
import { ELEMENTS } from '@/lib/game/constants';
import type { SpellCost } from '@/lib/game/types';
// Re-export number formatting functions from computed-stats.ts
export { fmt, fmtDec } from './computed-stats';
/**
* Format a spell cost for display
*/
export function formatSpellCost(cost: SpellCost): string {
if (cost.type === 'raw') {
return `${cost.amount} raw`;
}
const elemDef = ELEMENTS[cost.element || ''];
return `${cost.amount} ${elemDef?.sym || '?'}`;
}
/**
* Get the display color for a spell cost
*/
export function getSpellCostColor(cost: SpellCost): string {
if (cost.type === 'raw') {
return '#60A5FA'; // Blue for raw mana
}
return ELEMENTS[cost.element || '']?.color || '#9CA3AF';
}
/**
* Format study time in hours to human-readable string
*/
export function formatStudyTime(hours: number): string {
if (hours < 1) return `${Math.round(hours * 60)}m`;
return `${hours.toFixed(1)}h`;
}
/**
* Format time (hour of day) to HH:MM format
*/
export function formatHour(hour: number): string {
const h = Math.floor(hour);
const m = Math.floor((hour % 1) * 60);
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
}

View File

@@ -0,0 +1,63 @@
// ─── Navigation Slice ─────────────────────────────────────────────────────────
// Actions for floor navigation: climbing direction and manual floor changes
import type { GameState } from './types';
import { getFloorMaxHP } from './computed-stats';
// ─── Navigation Actions Interface ─────────────────────────────────────────────
export interface NavigationActions {
// Floor Navigation
setClimbDirection: (direction: 'up' | 'down') => void;
changeFloor: (direction: 'up' | 'down') => void;
}
// ─── Navigation Slice Factory ─────────────────────────────────────────────────
export function createNavigationSlice(
set: (partial: Partial<GameState> | ((state: GameState) => Partial<GameState>)) => void,
get: () => GameState
): NavigationActions {
return {
// Set the climbing direction (up or down)
setClimbDirection: (direction: 'up' | 'down') => {
set({ climbDirection: direction });
},
// Manually change floors by one
changeFloor: (direction: 'up' | 'down') => {
const state = get();
const currentFloor = state.currentFloor;
// Calculate next floor
const nextFloor = direction === 'up'
? Math.min(currentFloor + 1, 100)
: Math.max(currentFloor - 1, 1);
// Can't stay on same floor
if (nextFloor === currentFloor) return;
// Mark current floor as cleared (it will respawn when we come back)
const clearedFloors = { ...state.clearedFloors };
clearedFloors[currentFloor] = true;
// Check if next floor was cleared (needs respawn)
const nextFloorCleared = clearedFloors[nextFloor];
if (nextFloorCleared) {
// Respawn the floor
delete clearedFloors[nextFloor];
}
set({
currentFloor: nextFloor,
floorMaxHP: getFloorMaxHP(nextFloor),
floorHP: getFloorMaxHP(nextFloor),
maxFloorReached: Math.max(state.maxFloorReached, nextFloor),
clearedFloors,
climbDirection: direction,
equipmentSpellStates: state.equipmentSpellStates.map(s => ({ ...s, castProgress: 0 })),
log: [`🚶 Moved to floor ${nextFloor}${nextFloorCleared ? ' (respawned)' : ''}.`, ...state.log.slice(0, 49)],
});
},
};
}

797
src/lib/game/skill-evolution.ts Executable file
View File

@@ -0,0 +1,797 @@
// ─── Skill Evolution System ───────────────────────────────────────────────────────
// Each base skill has 5 tiers of evolution
// At level 5 and 10, you choose 2 out of 4 upgrades
// At max level (10), you tier up to the next evolution
// Tier multiplier: each tier is 10x more powerful (so tier N level 1 = tier N-1 level 10)
import type { SkillDef, SkillUpgradeChoice, SkillEvolutionPath, SkillTierDef } from './types';
// ─── Upgrade Choice Definitions ───────────────────────────────────────────────────
// Mana Well Upgrades (Tiers 1-5)
// Mana Well: +100 max mana per level
const MANA_WELL_TIER1_UPGRADES_L5: SkillUpgradeChoice[] = [
{ id: 'mw_t1_l5_capacity', name: 'Expanded Capacity', desc: '+25% max mana bonus', milestone: 5, effect: { type: 'multiplier', stat: 'maxMana', value: 1.25 } },
{ id: 'mw_t1_l5_regen', name: 'Natural Spring', desc: '+0.5 regen per hour', milestone: 5, effect: { type: 'bonus', stat: 'regen', value: 0.5 } },
{ id: 'mw_t1_l5_threshold', name: 'Mana Threshold', desc: '+20% max mana but reduces natural regen by 10%', milestone: 5, effect: { type: 'special', specialId: 'manaThreshold', specialDesc: 'Trade regen for capacity' } },
{ id: 'mw_t1_l5_desperate', name: 'Desperate Wells', desc: '+50% regen when below 25% mana', milestone: 5, effect: { type: 'special', specialId: 'desperateWells', specialDesc: 'Emergency regen boost' } },
];
const MANA_WELL_TIER1_UPGRADES_L10: SkillUpgradeChoice[] = [
{ id: 'mw_t1_l10_echo', name: 'Mana Echo', desc: '10% chance to gain double mana from clicks', milestone: 10, effect: { type: 'special', specialId: 'manaEcho', specialDesc: 'Double mana chance' } },
{ id: 'mw_t1_l10_reserve', name: 'Emergency Reserve', desc: 'Keep 10% max mana when starting a new loop', milestone: 10, effect: { type: 'special', specialId: 'emergencyReserve', specialDesc: 'Keep mana on loop' } },
{ id: 'mw_t1_l10_efficiency', name: 'Mana Efficiency', desc: '-5% spell costs', milestone: 10, effect: { type: 'multiplier', stat: 'spellCost', value: 0.95 } },
{ id: 'mw_t1_l10_meditation', name: 'Deep Wellspring', desc: '+50% meditation efficiency', milestone: 10, effect: { type: 'multiplier', stat: 'meditationEfficiency', value: 1.5 } },
];
// Mana Flow Upgrades (+1 regen per level)
const MANA_FLOW_TIER1_UPGRADES_L5: SkillUpgradeChoice[] = [
{ id: 'mf_t1_l5_rapid', name: 'Rapid Flow', desc: '+25% regen speed', milestone: 5, effect: { type: 'multiplier', stat: 'regen', value: 1.25 } },
{ id: 'mf_t1_l5_steady', name: 'Steady Stream', desc: 'Regen never drops below base even with incursion', milestone: 5, effect: { type: 'special', specialId: 'steadyStream', specialDesc: 'Immune to regen reduction' } },
{ id: 'mf_t1_l5_cascade', name: 'Mana Cascade', desc: '+0.1 regen per 100 max mana', milestone: 5, effect: { type: 'special', specialId: 'manaCascade', specialDesc: 'Scaling regen' } },
{ id: 'mf_t1_l5_overflow', name: 'Mana Overflow', desc: 'Raw mana can exceed max by 20%', milestone: 5, effect: { type: 'special', specialId: 'manaOverflow', specialDesc: 'Mana overfill' } },
];
const MANA_FLOW_TIER1_UPGRADES_L10: SkillUpgradeChoice[] = [
{ id: 'mf_t1_l10_torrent', name: 'Mana Torrent', desc: '+50% regen when above 75% mana', milestone: 10, effect: { type: 'special', specialId: 'manaTorrent', specialDesc: 'High mana regen bonus' } },
{ id: 'mf_t1_l10_ambient', name: 'Ambient Absorption', desc: '+1 regen permanently (persists through loops)', milestone: 10, effect: { type: 'bonus', stat: 'permanentRegen', value: 1 } },
{ id: 'mf_t1_l10_surge', name: 'Flow Surge', desc: 'Clicks restore 2x regen for 1 hour', milestone: 10, effect: { type: 'special', specialId: 'flowSurge', specialDesc: 'Click boosts regen' } },
{ id: 'mf_t1_l10_mastery', name: 'Flow Mastery', desc: '+10% mana from all sources', milestone: 10, effect: { type: 'multiplier', stat: 'allManaSources', value: 1.1 } },
];
// Combat Training Upgrades (+5 base damage per level)
const COMBAT_TRAIN_TIER1_UPGRADES_L5: SkillUpgradeChoice[] = [
{ id: 'ct_t1_l5_power', name: 'Raw Power', desc: '+25% base damage', milestone: 5, effect: { type: 'multiplier', stat: 'baseDamage', value: 1.25 } },
{ id: 'ct_t1_l5_crit', name: 'Critical Eye', desc: '+10% critical hit chance', milestone: 5, effect: { type: 'bonus', stat: 'critChance', value: 10 } },
{ id: 'ct_t1_l5_firstStrike', name: 'Power Strike', desc: '+15% damage on first attack each floor', milestone: 5, effect: { type: 'special', specialId: 'firstStrike', specialDesc: 'Opening attack bonus' } },
{ id: 'ct_t1_l5_speed', name: 'Quick Strikes', desc: '+20% attack speed', milestone: 5, effect: { type: 'multiplier', stat: 'attackSpeed', value: 1.2 } },
];
const COMBAT_TRAIN_TIER1_UPGRADES_L10: SkillUpgradeChoice[] = [
{ id: 'ct_t1_l10_overpower', name: 'Overpower', desc: '+50% damage when mana above 80%', milestone: 10, effect: { type: 'special', specialId: 'overpower', specialDesc: 'High mana damage bonus' } },
{ id: 'ct_t1_l10_berserker', name: 'Berserker', desc: '+50% damage when below 50% mana', milestone: 10, effect: { type: 'special', specialId: 'berserker', specialDesc: 'Low mana damage bonus' } },
{ id: 'ct_t1_l10_combo', name: 'Combo Master', desc: 'Every 5th attack deals 3x damage', milestone: 10, effect: { type: 'special', specialId: 'comboMaster', specialDesc: 'Combo finisher' } },
{ id: 'ct_t1_l10_adrenaline', name: 'Adrenaline Rush', desc: 'Defeating an enemy restores 5% mana', milestone: 10, effect: { type: 'special', specialId: 'adrenalineRush', specialDesc: 'Kill restore' } },
];
// Quick Learner Upgrades (+10% study speed per level)
const QUICK_LEARNER_TIER1_UPGRADES_L5: SkillUpgradeChoice[] = [
{ id: 'ql_t1_l5_focus', name: 'Deep Focus', desc: '+25% study speed', milestone: 5, effect: { type: 'multiplier', stat: 'studySpeed', value: 1.25 } },
{ id: 'ql_t1_l5_recall', name: 'Quick Grasp', desc: '5% chance for double study progress per hour', milestone: 5, effect: { type: 'special', specialId: 'quickGrasp', specialDesc: 'Double study progress chance' } },
{ id: 'ql_t1_l5_mastery', name: 'Quick Mastery', desc: '-20% study time for final 3 levels', milestone: 5, effect: { type: 'special', specialId: 'quickMastery', specialDesc: 'Faster final levels' } },
{ id: 'ql_t1_l5_parallel', name: 'Parallel Study', desc: 'Can study 2 things at once at 50% speed each', milestone: 5, effect: { type: 'special', specialId: 'parallelStudy', specialDesc: 'Dual study' } },
];
const QUICK_LEARNER_TIER1_UPGRADES_L10: SkillUpgradeChoice[] = [
{ id: 'ql_t1_l10_concentration', name: 'Deep Concentration', desc: '+20% study speed when mana > 90%', milestone: 10, effect: { type: 'special', specialId: 'deepConcentration', specialDesc: 'High mana study bonus' } },
{ id: 'ql_t1_l10_momentum', name: 'Study Momentum', desc: '+5% study speed for each consecutive hour (max 50%)', milestone: 10, effect: { type: 'special', specialId: 'studyMomentum', specialDesc: 'Consecutive study bonus' } },
{ id: 'ql_t1_l10_echo', name: 'Knowledge Echo', desc: '10% chance to instantly complete study', milestone: 10, effect: { type: 'special', specialId: 'knowledgeEcho', specialDesc: 'Instant study chance' } },
{ id: 'ql_t1_l10_transfer', name: 'Knowledge Transfer', desc: 'New spells/skills start at 10% progress', milestone: 10, effect: { type: 'special', specialId: 'knowledgeTransfer', specialDesc: 'Starting progress' } },
];
// Focused Mind Upgrades (-5% study cost per level)
const FOCUSED_MIND_TIER1_UPGRADES_L5: SkillUpgradeChoice[] = [
{ id: 'fm_t1_l5_efficiency', name: 'Mind Efficiency', desc: '+25% cost reduction', milestone: 5, effect: { type: 'multiplier', stat: 'costReduction', value: 1.25 } },
{ id: 'fm_t1_l5_clarity', name: 'Mental Clarity', desc: 'Study speed +10% when mana is above 75%', milestone: 5, effect: { type: 'special', specialId: 'mentalClarity', specialDesc: 'High mana study bonus' } },
{ id: 'fm_t1_l5_refund', name: 'Study Refund', desc: 'Get 25% mana back when study completes', milestone: 5, effect: { type: 'special', specialId: 'studyRefund', specialDesc: 'Study completion refund' } },
{ id: 'fm_t1_l5_discount', name: 'Bulk Discount', desc: '-10% cost for tier 2+ skills/spells', milestone: 5, effect: { type: 'special', specialId: 'bulkDiscount', specialDesc: 'High tier discount' } },
];
const FOCUSED_MIND_TIER1_UPGRADES_L10: SkillUpgradeChoice[] = [
{ id: 'fm_t1_l10_efficient', name: 'Efficient Learning', desc: '-10% study mana cost', milestone: 10, effect: { type: 'multiplier', stat: 'studyCost', value: 0.9 } },
{ id: 'fm_t1_l10_understanding', name: 'Deep Understanding', desc: '+10% bonus from all skill levels', milestone: 10, effect: { type: 'special', specialId: 'deepUnderstanding', specialDesc: 'Enhanced skills' } },
{ id: 'fm_t1_l10_rush', name: 'Study Rush', desc: 'First hour of study is 2x speed', milestone: 10, effect: { type: 'special', specialId: 'studyRush', specialDesc: 'Fast first hour' } },
{ id: 'fm_t1_l10_chain', name: 'Chain Study', desc: '-5% cost for each skill already maxed', milestone: 10, effect: { type: 'special', specialId: 'chainStudy', specialDesc: 'Learning synergy' } },
];
// Elemental Attunement Upgrades (+50 element cap per level)
const ELEM_ATTUNE_TIER1_UPGRADES_L5: SkillUpgradeChoice[] = [
{ id: 'ea_t1_l5_expand', name: 'Expanded Attunement', desc: '+25% element cap', milestone: 5, effect: { type: 'multiplier', stat: 'elementCap', value: 1.25 } },
{ id: 'ea_t1_l5_surge', name: 'Elemental Surge', desc: '+15% elemental damage', milestone: 5, effect: { type: 'multiplier', stat: 'elementalDamage', value: 1.15 } },
{ id: 'ea_t1_l5_expand2', name: 'Element Mastery', desc: '+10% element capacity', milestone: 5, effect: { type: 'bonus', stat: 'elementCap', value: 10 } },
{ id: 'ea_t1_l5_affinity', name: 'Elemental Affinity', desc: 'Newly unlocked elements start with 10 capacity', milestone: 5, effect: { type: 'special', specialId: 'elementalAffinity', specialDesc: 'Starting element capacity' } },
];
const ELEM_ATTUNE_TIER1_UPGRADES_L10: SkillUpgradeChoice[] = [
{ id: 'ea_t1_l10_master', name: 'Element Master', desc: '+20% elemental damage to all spells', milestone: 10, effect: { type: 'multiplier', stat: 'elementalDamage', value: 1.2 } },
{ id: 'ea_t1_l10_power', name: 'Elemental Power', desc: '+15% elemental damage', milestone: 10, effect: { type: 'multiplier', stat: 'elementalDamage', value: 1.15 } },
{ id: 'ea_t1_l10_resonance', name: 'Elemental Resonance', desc: 'Using element spells restores 1 of that element', milestone: 10, effect: { type: 'special', specialId: 'elementalResonance', specialDesc: 'Spell use restores element' } },
{ id: 'ea_t1_l10_exotic', name: 'Exotic Mastery', desc: '+20% exotic element damage', milestone: 10, effect: { type: 'special', specialId: 'exoticMastery', specialDesc: 'Exotic damage bonus' } },
];
// ─── Skill Evolution Paths ─────────────────────────────────────────────────────
// Tier multiplier: Each tier is 10x more powerful
// So Tier 2 level 1 = Tier 1 level 10, Tier 3 level 1 = Tier 2 level 10, etc.
export const SKILL_EVOLUTION_PATHS: Record<string, SkillEvolutionPath> = {
manaWell: {
baseSkillId: 'manaWell',
tiers: [
{
tier: 1,
skillId: 'manaWell',
name: 'Mana Well',
multiplier: 1,
upgrades: [...MANA_WELL_TIER1_UPGRADES_L5, ...MANA_WELL_TIER1_UPGRADES_L10],
},
{
tier: 2,
skillId: 'manaWell_t2',
name: 'Deep Reservoir',
multiplier: 10,
upgrades: [
{ id: 'mw_t2_l5_depth', name: 'Abyssal Depth', desc: '+50% max mana', milestone: 5, effect: { type: 'multiplier', stat: 'maxMana', value: 1.5 } },
{ id: 'mw_t2_l5_ancient', name: 'Ancient Well', desc: '+500 starting mana per loop', milestone: 5, effect: { type: 'bonus', stat: 'startingMana', value: 500 } },
{ id: 'mw_t2_l5_condense', name: 'Mana Condense', desc: 'Max mana +1% per 1000 total mana gathered', milestone: 5, effect: { type: 'special', specialId: 'manaCondense', specialDesc: 'Scaling max mana' } },
{ id: 'mw_t2_l5_reserve', name: 'Deep Reserve', desc: 'Regen +0.5 per 100 max mana', milestone: 5, effect: { type: 'special', specialId: 'deepReserve', specialDesc: 'Max mana scaling regen' } },
{ id: 'mw_t2_l10_ocean', name: 'Ocean of Mana', desc: '+1000 max mana', milestone: 10, effect: { type: 'bonus', stat: 'maxMana', value: 1000 } },
{ id: 'mw_t2_l10_tide', name: 'Mana Tide', desc: 'Mana regeneration pulses with time (+/- 50%)', milestone: 10, effect: { type: 'special', specialId: 'manaTide', specialDesc: 'Cyclic regen' } },
{ id: 'mw_t2_l10_void', name: 'Void Storage', desc: 'Store up to 150% max mana temporarily', milestone: 10, effect: { type: 'special', specialId: 'voidStorage', specialDesc: 'Overfill mana' } },
{ id: 'mw_t2_l10_core', name: 'Mana Core', desc: 'Gain mana regen equal to 0.5% of max mana', milestone: 10, effect: { type: 'special', specialId: 'manaCore', specialDesc: 'Max mana based regen' } },
],
},
{
tier: 3,
skillId: 'manaWell_t3',
name: 'Abyssal Pool',
multiplier: 100,
upgrades: [
{ id: 'mw_t3_l5_abyss', name: 'Abyssal Power', desc: '+100% max mana', milestone: 5, effect: { type: 'multiplier', stat: 'maxMana', value: 2 } },
{ id: 'mw_t3_l5_siphon', name: 'Mana Siphon', desc: 'Convert 1% of floor HP to mana on defeat', milestone: 5, effect: { type: 'special', specialId: 'manaSiphon', specialDesc: 'HP to mana conversion' } },
{ id: 'mw_t3_l5_heart', name: 'Mana Heart', desc: '+10% max mana per loop completed', milestone: 5, effect: { type: 'special', specialId: 'manaHeart', specialDesc: 'Loop scaling mana' } },
{ id: 'mw_t3_l5_ancient', name: 'Ancient Reserve', desc: 'Start each loop with 25% max mana', milestone: 5, effect: { type: 'special', specialId: 'ancientReserve', specialDesc: 'Loop starting mana' } },
{ id: 'mw_t3_l10_realm', name: 'Mana Realm', desc: '+5000 max mana', milestone: 10, effect: { type: 'bonus', stat: 'maxMana', value: 5000 } },
{ id: 'mw_t3_l10_avatar', name: 'Mana Avatar', desc: 'When full, all spells cost 50% less', milestone: 10, effect: { type: 'special', specialId: 'manaAvatar', specialDesc: 'Full mana discount' } },
{ id: 'mw_t3_l10_genesis', name: 'Mana Genesis', desc: 'Generate 1% max mana per hour passively', milestone: 10, effect: { type: 'special', specialId: 'manaGenesis', specialDesc: 'Passive mana generation' } },
{ id: 'mw_t3_l10_reflect', name: 'Mana Reflect', desc: '10% chance to reflect spell cost as damage', milestone: 10, effect: { type: 'special', specialId: 'manaReflect', specialDesc: 'Cost to damage' } },
],
},
{
tier: 4,
skillId: 'manaWell_t4',
name: 'Ocean of Power',
multiplier: 1000,
upgrades: [
{ id: 'mw_t4_l5_tsunami', name: 'Mana Tsunami', desc: '+200% max mana', milestone: 5, effect: { type: 'multiplier', stat: 'maxMana', value: 3 } },
{ id: 'mw_t4_l5_breath', name: 'Deep Breath', desc: 'Meditate for 1 hour = fill 50% mana', milestone: 5, effect: { type: 'special', specialId: 'deepBreath', specialDesc: 'Quick fill' } },
{ id: 'mw_t4_l5_sovereign', name: 'Mana Sovereign', desc: 'All mana costs reduced by 20%', milestone: 5, effect: { type: 'multiplier', stat: 'allCosts', value: 0.8 } },
{ id: 'mw_t4_l5_wellspring', name: 'Primordial Wellspring', desc: 'Clicks give 5% of max mana', milestone: 5, effect: { type: 'special', specialId: 'primordialWellspring', specialDesc: 'Max mana clicks' } },
{ id: 'mw_t4_l10_infinite', name: 'Infinite Reservoir', desc: '+50000 max mana', milestone: 10, effect: { type: 'bonus', stat: 'maxMana', value: 50000 } },
{ id: 'mw_t4_l10_ascend', name: 'Mana Conduit', desc: 'Meditation also regenerates 5% max elemental mana per hour', milestone: 10, effect: { type: 'special', specialId: 'manaConduit', specialDesc: 'Meditation element regen' } },
{ id: 'mw_t4_l10_nova', name: 'Mana Nova', desc: 'When taking damage, release 5% mana as damage', milestone: 10, effect: { type: 'special', specialId: 'manaNova', specialDesc: 'Defensive burst' } },
{ id: 'mw_t4_l10_overflow', name: 'Mana Overflow', desc: 'Excess mana from clicks is doubled', milestone: 10, effect: { type: 'special', specialId: 'manaOverflowT4', specialDesc: 'Click overflow' } },
],
},
{
tier: 5,
skillId: 'manaWell_t5',
name: 'Infinite Reservoir',
multiplier: 10000,
upgrades: [
{ id: 'mw_t5_l5_cosmic', name: 'Cosmic Mana', desc: '+500% max mana', milestone: 5, effect: { type: 'multiplier', stat: 'maxMana', value: 6 } },
{ id: 'mw_t5_l5_omega', name: 'Omega Well', desc: 'All mana effects +50%', milestone: 5, effect: { type: 'multiplier', stat: 'manaEffects', value: 1.5 } },
{ id: 'mw_t5_l5_origin', name: 'Origin Point', desc: 'Start loops with 100% mana', milestone: 5, effect: { type: 'special', specialId: 'originPoint', specialDesc: 'Full start' } },
{ id: 'mw_t5_l5_zenith', name: 'Mana Zenith', desc: 'At max mana, deal +50% damage', milestone: 5, effect: { type: 'special', specialId: 'manaZenith', specialDesc: 'Max mana damage' } },
{ id: 'mw_t5_l10_godhood', name: 'Mana Godhood', desc: '+100000 max mana', milestone: 10, effect: { type: 'bonus', stat: 'maxMana', value: 100000 } },
{ id: 'mw_t5_l10_ultimate', name: 'Ultimate Reservoir', desc: 'All spells enhanced by 1% per 1000 max mana', milestone: 10, effect: { type: 'special', specialId: 'ultimateReservoir', specialDesc: 'Mana scaling spells' } },
{ id: 'mw_t5_l10_immortal', name: 'Immortal Mana', desc: 'Mana regeneration never stops', milestone: 10, effect: { type: 'special', specialId: 'immortalMana', specialDesc: 'Always regen' } },
{ id: 'mw_t5_l10_victory', name: 'Mana Victory', desc: 'Alternative victory: reach 1M mana', milestone: 10, effect: { type: 'special', specialId: 'manaVictory', specialDesc: 'Mana victory' } },
],
},
],
},
manaFlow: {
baseSkillId: 'manaFlow',
tiers: [
{
tier: 1,
skillId: 'manaFlow',
name: 'Mana Flow',
multiplier: 1,
upgrades: [...MANA_FLOW_TIER1_UPGRADES_L5, ...MANA_FLOW_TIER1_UPGRADES_L10],
},
{
tier: 2,
skillId: 'manaFlow_t2',
name: 'Rushing Stream',
multiplier: 10,
upgrades: [
{ id: 'mf_t2_l5_river', name: 'River of Mana', desc: '+50% regen', milestone: 5, effect: { type: 'multiplier', stat: 'regen', value: 1.5 } },
{ id: 'mf_t2_l5_flood', name: 'Mana Flood', desc: 'Regen +2 per guardian defeated', milestone: 5, effect: { type: 'special', specialId: 'manaFlood', specialDesc: 'Guardian regen' } },
{ id: 'mf_t2_l5_whirlpool', name: 'Mana Whirlpool', desc: 'Convert overflow mana to random elements', milestone: 5, effect: { type: 'special', specialId: 'manaWhirlpool', specialDesc: 'Overflow conversion' } },
{ id: 'mf_t2_l5_current', name: 'Swift Current', desc: '+25% regen during combat', milestone: 5, effect: { type: 'special', specialId: 'swiftCurrent', specialDesc: 'Combat regen' } },
{ id: 'mf_t2_l10_cascade', name: 'Mana Cascade', desc: '+20 regen', milestone: 10, effect: { type: 'bonus', stat: 'regen', value: 20 } },
{ id: 'mf_t2_l10_storm', name: 'Mana Storm', desc: 'Every 6 hours, gain 500 mana instantly', milestone: 10, effect: { type: 'special', specialId: 'manaStorm', specialDesc: 'Periodic burst' } },
{ id: 'mf_t2_l10_tributary', name: 'Tributary Flow', desc: '+0.5 regen per learned spell', milestone: 10, effect: { type: 'special', specialId: 'tributaryFlow', specialDesc: 'Spell regen' } },
{ id: 'mf_t2_l10_eternal', name: 'Eternal Flow', desc: 'Regen is immune to incursion penalty', milestone: 10, effect: { type: 'special', specialId: 'eternalFlow', specialDesc: 'Incursion immunity' } },
],
},
{
tier: 3,
skillId: 'manaFlow_t3',
name: 'Eternal River',
multiplier: 100,
upgrades: [
{ id: 'mf_t3_l5_ocean', name: 'Ocean Current', desc: '+100% regen', milestone: 5, effect: { type: 'multiplier', stat: 'regen', value: 2 } },
{ id: 'mf_t3_l5_tide', name: 'Tidal Force', desc: 'Regen varies with time of day (0.5x to 2x)', milestone: 5, effect: { type: 'special', specialId: 'tidalForce', specialDesc: 'Time scaling' } },
{ id: 'mf_t3_l5_abyss', name: 'Abyssal Current', desc: '+1 regen per floor reached', milestone: 5, effect: { type: 'special', specialId: 'abyssalCurrent', specialDesc: 'Floor regen' } },
{ id: 'mf_t3_l5_monsoon', name: 'Mana Monsoon', desc: '+5 regen per loop completed', milestone: 5, effect: { type: 'special', specialId: 'manaMonsoon', specialDesc: 'Loop regen' } },
{ id: 'mf_t3_l10_deluge', name: 'Mana Deluge', desc: '+100 regen', milestone: 10, effect: { type: 'bonus', stat: 'regen', value: 100 } },
{ id: 'mf_t3_l10_fountain', name: 'Infinite Fountain', desc: 'Mana regen has no upper limit to overflow', milestone: 10, effect: { type: 'special', specialId: 'infiniteFountain', specialDesc: 'Always regen' } },
{ id: 'mf_t3_l10_source', name: 'Primordial Source', desc: 'Regen +1% of max mana per hour', milestone: 10, effect: { type: 'special', specialId: 'primordialSource', specialDesc: 'Max mana regen' } },
{ id: 'mf_t3_l10_blessing', name: 'River Blessing', desc: 'Spells cost 1 less mana minimum (min 1)', milestone: 10, effect: { type: 'special', specialId: 'riverBlessing', specialDesc: 'Min cost reduction' } },
],
},
{
tier: 4,
skillId: 'manaFlow_t4',
name: 'Cosmic Torrent',
multiplier: 1000,
upgrades: [
{ id: 'mf_t4_l5_nova', name: 'Mana Nova', desc: '+200% regen', milestone: 5, effect: { type: 'multiplier', stat: 'regen', value: 3 } },
{ id: 'mf_t4_l5_nebula', name: 'Nebula Flow', desc: 'Gain 10% regen from all actions', milestone: 5, effect: { type: 'special', specialId: 'nebulaFlow', specialDesc: 'Action mana' } },
{ id: 'mf_t4_l5_constellation', name: 'Constellation Link', desc: '+5 regen per skill maxed', milestone: 5, effect: { type: 'special', specialId: 'constellationLink', specialDesc: 'Skill regen' } },
{ id: 'mf_t4_l5_supernova', name: 'Supernova Burst', desc: 'Once per loop, instantly fill all mana', milestone: 5, effect: { type: 'special', specialId: 'supernovaBurst', specialDesc: 'Loop burst' } },
{ id: 'mf_t4_l10_galaxy', name: 'Galaxy Flow', desc: '+500 regen', milestone: 10, effect: { type: 'bonus', stat: 'regen', value: 500 } },
{ id: 'mf_t4_l10_universe', name: 'Universal Mana', desc: 'All mana sources +100%', milestone: 10, effect: { type: 'multiplier', stat: 'allManaSources', value: 2 } },
{ id: 'mf_t4_l10_omega', name: 'Omega Flow', desc: 'Regen = max mana / 50', milestone: 10, effect: { type: 'special', specialId: 'omegaFlow', specialDesc: 'Max mana scaling' } },
{ id: 'mf_t4_l10_zenith', name: 'Flow Zenith', desc: 'At peak hours (12:00), gain 10x regen', milestone: 10, effect: { type: 'special', specialId: 'flowZenith', specialDesc: 'Peak hours bonus' } },
],
},
{
tier: 5,
skillId: 'manaFlow_t5',
name: 'Infinite Cascade',
multiplier: 10000,
upgrades: [
{ id: 'mf_t5_l5_multiverse', name: 'Multiverse Flow', desc: '+500% regen', milestone: 5, effect: { type: 'multiplier', stat: 'regen', value: 6 } },
{ id: 'mf_t5_l5_dimension', name: 'Dimensional Tap', desc: 'Draw mana from alternate dimensions (+50% all sources)', milestone: 5, effect: { type: 'multiplier', stat: 'allManaSources', value: 1.5 } },
{ id: 'mf_t5_l5_omniscience', name: 'Omniscient Flow', desc: 'Know when mana peaks (predict high regen times)', milestone: 5, effect: { type: 'special', specialId: 'omniscientFlow', specialDesc: 'Peak prediction' } },
{ id: 'mf_t5_l5_ultimate', name: 'Ultimate Stream', desc: 'All mana effects doubled', milestone: 5, effect: { type: 'multiplier', stat: 'manaEffects', value: 2 } },
{ id: 'mf_t5_l10_godhood', name: 'Flow Godhood', desc: '+2000 regen', milestone: 10, effect: { type: 'bonus', stat: 'regen', value: 2000 } },
{ id: 'mf_t5_l10_infinity', name: 'Infinite Flow', desc: 'Mana regeneration has no limits', milestone: 10, effect: { type: 'special', specialId: 'infiniteFlowRegen', specialDesc: 'Uncapped regen' } },
{ id: 'mf_t5_l10_transcend', name: 'Flow Transcendence', desc: 'Become one with mana flow (all actions give mana)', milestone: 10, effect: { type: 'special', specialId: 'flowTranscendence', specialDesc: 'Mana unity' } },
{ id: 'mf_t5_l10_victory', name: 'Flow Victory', desc: 'Victory: regenerate 10000 mana/hour', milestone: 10, effect: { type: 'special', specialId: 'flowVictory', specialDesc: 'Flow victory' } },
],
},
],
},
combatTrain: {
baseSkillId: 'combatTrain',
tiers: [
{
tier: 1,
skillId: 'combatTrain',
name: 'Combat Training',
multiplier: 1,
upgrades: [...COMBAT_TRAIN_TIER1_UPGRADES_L5, ...COMBAT_TRAIN_TIER1_UPGRADES_L10],
},
{
tier: 2,
skillId: 'combatTrain_t2',
name: 'Warrior Instinct',
multiplier: 10,
upgrades: [
{ id: 'ct_t2_l5_mastery', name: 'Combat Mastery', desc: '+50% base damage', milestone: 5, effect: { type: 'multiplier', stat: 'baseDamage', value: 1.5 } },
{ id: 'ct_t2_l5_cleave', name: 'Cleave', desc: 'Deal 25% damage to next floor enemy', milestone: 5, effect: { type: 'special', specialId: 'cleave', specialDesc: 'Multi-floor damage' } },
{ id: 'ct_t2_l5_berserk', name: 'Berserk Training', desc: '+5% damage per consecutive hit (max +100%)', milestone: 5, effect: { type: 'special', specialId: 'berserkTraining', specialDesc: 'Consecutive bonus' } },
{ id: 'ct_t2_l5_weapon', name: 'Weapon Mastery', desc: '+25% equipment damage bonuses', milestone: 5, effect: { type: 'multiplier', stat: 'equipmentDamage', value: 1.25 } },
{ id: 'ct_t2_l10_devastate', name: 'Devastation', desc: '+100 base damage', milestone: 10, effect: { type: 'bonus', stat: 'baseDamage', value: 100 } },
{ id: 'ct_t2_l10_streak', name: 'Kill Streak', desc: '+5% damage per kill this loop (max +100%)', milestone: 10, effect: { type: 'special', specialId: 'killStreak', specialDesc: 'Kill scaling' } },
{ id: 'ct_t2_l10_finisher', name: 'Finisher', desc: '+100% damage to enemies below 50% HP', milestone: 10, effect: { type: 'special', specialId: 'finisherBonus', specialDesc: 'Execute mastery' } },
{ id: 'ct_t2_l10_frenzy', name: 'Battle Frenzy', desc: 'Attack speed +50% for 1 hour after kill', milestone: 10, effect: { type: 'special', specialId: 'battleFrenzy', specialDesc: 'Kill speed boost' } },
],
},
{
tier: 3,
skillId: 'combatTrain_t3',
name: 'Battlemaster',
multiplier: 100,
upgrades: [
{ id: 'ct_t3_l5_legendary', name: 'Legendary Combat', desc: '+100% base damage', milestone: 5, effect: { type: 'multiplier', stat: 'baseDamage', value: 2 } },
{ id: 'ct_t3_l5_annihilate', name: 'Annihilation', desc: '10% chance to deal 5x damage', milestone: 5, effect: { type: 'special', specialId: 'annihilation', specialDesc: 'Massive crit chance' } },
{ id: 'ct_t3_l5_bane', name: 'Guardian Bane+', desc: '+50% damage vs guardians', milestone: 5, effect: { type: 'special', specialId: 'guardianBanePlus', specialDesc: 'Guardian bonus' } },
{ id: 'ct_t3_l5_onslaught', name: 'Onslaught', desc: 'Each hit increases next hit by 5% (resets on floor clear)', milestone: 5, effect: { type: 'special', specialId: 'onslaught', specialDesc: 'Cumulative damage' } },
{ id: 'ct_t3_l10_dominator', name: 'Floor Dominator', desc: '+500 base damage', milestone: 10, effect: { type: 'bonus', stat: 'baseDamage', value: 500 } },
{ id: 'ct_t3_l10_aura', name: 'Battle Aura', desc: 'Passively deal 5% damage per hour while climbing', milestone: 10, effect: { type: 'special', specialId: 'battleAura', specialDesc: 'Passive damage' } },
{ id: 'ct_t3_l10_chain', name: 'Chain Strike', desc: '25% chance to hit again at 50% damage', milestone: 10, effect: { type: 'special', specialId: 'chainStrike', specialDesc: 'Chain attack' } },
{ id: 'ct_t3_l10_rage', name: 'Eternal Rage', desc: 'Damage increases by 10% per loop completed', milestone: 10, effect: { type: 'special', specialId: 'eternalRage', specialDesc: 'Loop scaling damage' } },
],
},
{
tier: 4,
skillId: 'combatTrain_t4',
name: 'Avatar of War',
multiplier: 1000,
upgrades: [
{ id: 'ct_t4_l5_godlike', name: 'Godlike Combat', desc: '+200% base damage', milestone: 5, effect: { type: 'multiplier', stat: 'baseDamage', value: 3 } },
{ id: 'ct_t4_l5_void', name: 'Void Strike', desc: 'Attacks deal 10% true damage (ignores defense)', milestone: 5, effect: { type: 'special', specialId: 'voidStrike', specialDesc: 'True damage' } },
{ id: 'ct_t4_l5_master', name: 'Combat Grandmaster', desc: 'All combat skills +2 levels', milestone: 5, effect: { type: 'special', specialId: 'combatGrandmaster', specialDesc: 'Skill boost' } },
{ id: 'ct_t4_l5_tempest', name: 'Tempest Strike', desc: 'Every 10th attack is a guaranteed crit', milestone: 5, effect: { type: 'special', specialId: 'tempestStrike', specialDesc: 'Guaranteed crit' } },
{ id: 'ct_t4_l10_destruction', name: 'Avatar of Destruction', desc: '+2000 base damage', milestone: 10, effect: { type: 'bonus', stat: 'baseDamage', value: 2000 } },
{ id: 'ct_t4_l10_apocalypse', name: 'Apocalypse Strike', desc: '1% chance to instantly clear floor', milestone: 10, effect: { type: 'special', specialId: 'apocalypseStrike', specialDesc: 'Instant clear' } },
{ id: 'ct_t4_l10_omega', name: 'Omega Strike', desc: 'Final hit on floor deals +300% damage to next floor', milestone: 10, effect: { type: 'special', specialId: 'omegaStrike', specialDesc: 'Finisher carryover' } },
{ id: 'ct_t4_l10_immortal', name: 'Immortal Warrior', desc: 'Combat unaffected by incursion', milestone: 10, effect: { type: 'special', specialId: 'immortalWarrior', specialDesc: 'Incursion immunity' } },
],
},
{
tier: 5,
skillId: 'combatTrain_t5',
name: 'Eternal Conqueror',
multiplier: 10000,
upgrades: [
{ id: 'ct_t5_l5_transcend', name: 'Transcendent Combat', desc: '+500% base damage', milestone: 5, effect: { type: 'multiplier', stat: 'baseDamage', value: 6 } },
{ id: 'ct_t5_l5_ultimate', name: 'Ultimate Warrior', desc: 'All attacks have +50% crit chance', milestone: 5, effect: { type: 'special', specialId: 'ultimateWarrior', specialDesc: 'Enhanced attacks' } },
{ id: 'ct_t5_l5_legend', name: 'Living Legend', desc: '+50% damage per loop completed', milestone: 5, effect: { type: 'special', specialId: 'livingLegend', specialDesc: 'Loop scaling' } },
{ id: 'ct_t5_l5_dominator', name: 'Absolute Dominator', desc: 'Guardians take 3x damage', milestone: 5, effect: { type: 'special', specialId: 'absoluteDominator', specialDesc: 'Triple guardian damage' } },
{ id: 'ct_t5_l10_godhood', name: 'War Godhood', desc: '+10000 base damage', milestone: 10, effect: { type: 'bonus', stat: 'baseDamage', value: 10000 } },
{ id: 'ct_t5_l10_oneshot', name: 'One Shot', desc: '5% chance to deal 50x damage', milestone: 10, effect: { type: 'special', specialId: 'oneShot', specialDesc: 'Massive damage' } },
{ id: 'ct_t5_l10_victory', name: 'Combat Victory', desc: 'Victory: defeat all guardians automatically', milestone: 10, effect: { type: 'special', specialId: 'combatVictory', specialDesc: 'Combat victory' } },
{ id: 'ct_t5_l10_omnipotence', name: 'Omnipotent Strike', desc: 'Every attack is a critical hit', milestone: 10, effect: { type: 'special', specialId: 'omnipotentStrike', specialDesc: 'Always crit' } },
],
},
],
},
quickLearner: {
baseSkillId: 'quickLearner',
tiers: [
{
tier: 1,
skillId: 'quickLearner',
name: 'Quick Learner',
multiplier: 1,
upgrades: [...QUICK_LEARNER_TIER1_UPGRADES_L5, ...QUICK_LEARNER_TIER1_UPGRADES_L10],
},
{
tier: 2,
skillId: 'quickLearner_t2',
name: 'Swift Scholar',
multiplier: 10,
upgrades: [
{ id: 'ql_t2_l5_genius', name: 'Study Genius', desc: '+50% study speed', milestone: 5, effect: { type: 'multiplier', stat: 'studySpeed', value: 1.5 } },
{ id: 'ql_t2_l5_snap', name: 'Snap Learning', desc: 'Instantly complete 10% of study when starting', milestone: 5, effect: { type: 'special', specialId: 'snapLearning', specialDesc: 'Study head start' } },
{ id: 'ql_t2_l5_archive', name: 'Mental Archive', desc: 'Keep 50% study progress between loops', milestone: 5, effect: { type: 'special', specialId: 'mentalArchive', specialDesc: 'Progress retention' } },
{ id: 'ql_t2_l5_rush', name: 'Study Rush+', desc: 'First 2 hours of study are 2x speed', milestone: 5, effect: { type: 'special', specialId: 'studyRushT2', specialDesc: 'Quick start' } },
{ id: 'ql_t2_l10_master', name: 'Study Master', desc: '+100% study speed', milestone: 10, effect: { type: 'multiplier', stat: 'studySpeed', value: 2 } },
{ id: 'ql_t2_l10_instant', name: 'Instant Grasp', desc: '5% chance to instantly learn', milestone: 10, effect: { type: 'special', specialId: 'instantGrasp', specialDesc: 'Instant learn chance' } },
{ id: 'ql_t2_l10_resonance', name: 'Knowledge Resonance', desc: 'Learning one thing speeds up others by 5%', milestone: 10, effect: { type: 'special', specialId: 'knowledgeResonance', specialDesc: 'Study synergy' } },
{ id: 'ql_t2_l10_adept', name: 'Quick Adept', desc: 'Tier 2+ studies are 25% faster', milestone: 10, effect: { type: 'special', specialId: 'quickAdept', specialDesc: 'High tier speed' } },
],
},
{
tier: 3,
skillId: 'quickLearner_t3',
name: 'Sage Mind',
multiplier: 100,
upgrades: [
{ id: 'ql_t3_l5_enlightenment', name: 'Enlightenment', desc: '+100% study speed', milestone: 5, effect: { type: 'multiplier', stat: 'studySpeed', value: 2 } },
{ id: 'ql_t3_l5_palace', name: 'Mind Palace+', desc: 'Store 3 skills for instant study next loop', milestone: 5, effect: { type: 'special', specialId: 'mindPalacePlus', specialDesc: 'Stored skills' } },
{ id: 'ql_t3_l5_burst', name: 'Study Burst', desc: 'First 50% of study takes half time', milestone: 5, effect: { type: 'special', specialId: 'studyBurst', specialDesc: 'Quick first half' } },
{ id: 'ql_t3_l5_legacy', name: 'Scholar Legacy', desc: 'Start loops with 1 random skill at level 1', milestone: 5, effect: { type: 'special', specialId: 'scholarLegacy', specialDesc: 'Starting skill' } },
{ id: 'ql_t3_l10_transcend', name: 'Study Transcendence', desc: 'Studies complete at 90% progress', milestone: 10, effect: { type: 'special', specialId: 'studyTranscendence', specialDesc: 'Early completion' } },
{ id: 'ql_t3_l10_overflow', name: 'Knowledge Overflow', desc: 'Excess study progress carries to next study', milestone: 10, effect: { type: 'special', specialId: 'knowledgeOverflow', specialDesc: 'Progress carryover' } },
{ id: 'ql_t3_l10_triple', name: 'Triple Mind', desc: 'Study 3 things at once at 33% speed each', milestone: 10, effect: { type: 'special', specialId: 'tripleMind', specialDesc: 'Triple study' } },
{ id: 'ql_t3_l10_ancient', name: 'Ancient Scholar', desc: 'Tier 4+ studies are 50% faster', milestone: 10, effect: { type: 'special', specialId: 'ancientScholar', specialDesc: 'High tier bonus' } },
],
},
{
tier: 4,
skillId: 'quickLearner_t4',
name: 'Cosmic Scholar',
multiplier: 1000,
upgrades: [
{ id: 'ql_t4_l5_cosmic', name: 'Cosmic Learning', desc: '+200% study speed', milestone: 5, effect: { type: 'multiplier', stat: 'studySpeed', value: 3 } },
{ id: 'ql_t4_l5_archive', name: 'Cosmic Archive', desc: 'Access all known spells/skills instantly for re-study', milestone: 5, effect: { type: 'special', specialId: 'cosmicArchive', specialDesc: 'All knowledge' } },
{ id: 'ql_t4_l5_dimension', name: 'Dimension Study', desc: 'Study continues in background while doing other actions', milestone: 5, effect: { type: 'special', specialId: 'dimensionStudy', specialDesc: 'Background study' } },
{ id: 'ql_t4_l5_grant', name: 'Knowledge Grant', desc: 'Each loop, gain 1 free random spell', milestone: 5, effect: { type: 'special', specialId: 'knowledgeGrant', specialDesc: 'Free spell per loop' } },
{ id: 'ql_t4_l10_omniscient', name: 'Omniscient Mind', desc: 'All studies are 50% faster', milestone: 10, effect: { type: 'multiplier', stat: 'allStudy', value: 1.5 } },
{ id: 'ql_t4_l10_infinite', name: 'Infinite Learning', desc: 'No maximum on study queue', milestone: 10, effect: { type: 'special', specialId: 'infiniteLearning', specialDesc: 'No learning cap' } },
{ id: 'ql_t4_l10_echo', name: 'Knowledge Echo+', desc: '20% instant learn chance', milestone: 10, effect: { type: 'special', specialId: 'knowledgeEchoPlus', specialDesc: 'Better instant' } },
{ id: 'ql_t4_l10_mastery', name: 'Study Mastery', desc: 'Completing study gives 25% mana back', milestone: 10, effect: { type: 'special', specialId: 'studyMastery', specialDesc: 'Completion refund' } },
],
},
{
tier: 5,
skillId: 'quickLearner_t5',
name: 'Omniscient Being',
multiplier: 10000,
upgrades: [
{ id: 'ql_t5_l5_godhood', name: 'Learning Godhood', desc: '+500% study speed', milestone: 5, effect: { type: 'multiplier', stat: 'studySpeed', value: 6 } },
{ id: 'ql_t5_l5_allknowing', name: 'All-Knowing', desc: 'See all unlock requirements', milestone: 5, effect: { type: 'special', specialId: 'allKnowing', specialDesc: 'All secrets' } },
{ id: 'ql_t5_l5_ultimate', name: 'Ultimate Scholar', desc: 'All learning is instant', milestone: 5, effect: { type: 'special', specialId: 'ultimateScholar', specialDesc: 'Instant all' } },
{ id: 'ql_t5_l5_transcend', name: 'Mind Transcendence', desc: 'Keep 5 skill levels across loops', milestone: 5, effect: { type: 'special', specialId: 'mindTranscendence', specialDesc: 'Unlimited retention' } },
{ id: 'ql_t5_l10_perfection', name: 'Perfect Learning', desc: 'All studies complete instantly', milestone: 10, effect: { type: 'special', specialId: 'perfectLearning', specialDesc: 'Instant mastery' } },
{ id: 'ql_t5_l10_victory', name: 'Knowledge Victory', desc: 'Victory: learn all things', milestone: 10, effect: { type: 'special', specialId: 'knowledgeVictory', specialDesc: 'Learn victory' } },
{ id: 'ql_t5_l10_eternal', name: 'Eternal Knowledge', desc: 'Keep all knowledge forever', milestone: 10, effect: { type: 'special', specialId: 'eternalKnowledge', specialDesc: 'Permanent knowledge' } },
{ id: 'ql_t5_l10_origin', name: 'Origin Mind', desc: 'Become the source of all knowledge', milestone: 10, effect: { type: 'special', specialId: 'originMind', specialDesc: 'Knowledge source' } },
],
},
],
},
focusedMind: {
baseSkillId: 'focusedMind',
tiers: [
{
tier: 1,
skillId: 'focusedMind',
name: 'Focused Mind',
multiplier: 1,
upgrades: [...FOCUSED_MIND_TIER1_UPGRADES_L5, ...FOCUSED_MIND_TIER1_UPGRADES_L10],
},
{
tier: 2,
skillId: 'focusedMind_t2',
name: 'Crystal Mind',
multiplier: 10,
upgrades: [
{ id: 'fm_t2_l5_clarity', name: 'Crystal Clarity', desc: '+50% cost reduction', milestone: 5, effect: { type: 'multiplier', stat: 'costReduction', value: 1.5 } },
{ id: 'fm_t2_l5_store', name: 'Mana Store', desc: 'Store up to 50% of study cost for next study', milestone: 5, effect: { type: 'special', specialId: 'manaStore', specialDesc: 'Cost storage' } },
{ id: 'fm_t2_l5_efficient', name: 'Efficient Mind', desc: 'Tier 2+ skills cost 20% less', milestone: 5, effect: { type: 'special', specialId: 'efficientMind', specialDesc: 'High tier discount' } },
{ id: 'fm_t2_l5_resonance', name: 'Cost Resonance', desc: 'Each study reduces next study cost by 5%', milestone: 5, effect: { type: 'special', specialId: 'costResonance', specialDesc: 'Cumulative discount' } },
{ id: 'fm_t2_l10_mastery', name: 'Cost Mastery', desc: 'All costs reduced by 25%', milestone: 10, effect: { type: 'multiplier', stat: 'allCosts', value: 0.75 } },
{ id: 'fm_t2_l10_refund', name: 'Full Refund', desc: 'Get 50% mana back when study completes', milestone: 10, effect: { type: 'special', specialId: 'fullRefund', specialDesc: 'Big refund' } },
{ id: 'fm_t2_l10_discount', name: 'Master Discount', desc: 'Skills cost 10% of base instead of level scaling', milestone: 10, effect: { type: 'special', specialId: 'masterDiscount', specialDesc: 'Flat cost' } },
{ id: 'fm_t2_l10_memory', name: 'Cost Memory', desc: 'First study of each type is free', milestone: 10, effect: { type: 'special', specialId: 'costMemory', specialDesc: 'Free first study' } },
],
},
{
tier: 3,
skillId: 'focusedMind_t3',
name: 'Void Mind',
multiplier: 100,
upgrades: [
{ id: 'fm_t3_l5_void', name: 'Void Focus', desc: '+100% cost reduction', milestone: 5, effect: { type: 'multiplier', stat: 'costReduction', value: 2 } },
{ id: 'fm_t3_l5_negate', name: 'Cost Negation', desc: '25% chance for study to be free', milestone: 5, effect: { type: 'special', specialId: 'costNegation', specialDesc: 'Free chance' } },
{ id: 'fm_t3_l5_reverse', name: 'Cost Reverse', desc: '10% chance to gain mana from study', milestone: 5, effect: { type: 'special', specialId: 'costReverse', specialDesc: 'Reverse cost' } },
{ id: 'fm_t3_l5_unlimited', name: 'Unlimited Focus', desc: 'Study cost can go below 1', milestone: 5, effect: { type: 'special', specialId: 'unlimitedFocus', specialDesc: 'No minimum' } },
{ id: 'fm_t3_l10_free', name: 'Mostly Free', desc: '50% of studies are free', milestone: 10, effect: { type: 'special', specialId: 'mostlyFree', specialDesc: 'Mostly free' } },
{ id: 'fm_t3_l10_zenith', name: 'Mind Zenith', desc: 'When mana is full, all studies free', milestone: 10, effect: { type: 'special', specialId: 'mindZenith', specialDesc: 'Peak free' } },
{ id: 'fm_t3_l10_ultimate', name: 'Ultimate Efficiency', desc: 'All costs are 10% of base', milestone: 10, effect: { type: 'multiplier', stat: 'allCosts', value: 0.1 } },
{ id: 'fm_t3_l10_infinite', name: 'Infinite Focus', desc: 'Never run out of mana for study', milestone: 10, effect: { type: 'special', specialId: 'infiniteFocus', specialDesc: 'Infinite study mana' } },
],
},
{
tier: 4,
skillId: 'focusedMind_t4',
name: 'Cosmic Mind',
multiplier: 1000,
upgrades: [
{ id: 'fm_t4_l5_cosmic', name: 'Cosmic Focus', desc: '+200% cost reduction', milestone: 5, effect: { type: 'multiplier', stat: 'costReduction', value: 3 } },
{ id: 'fm_t4_l5_void', name: 'Void Cost', desc: 'Studies draw from void instead of mana (75% free)', milestone: 5, effect: { type: 'special', specialId: 'voidCost', specialDesc: 'Void power' } },
{ id: 'fm_t4_l5_transcend', name: 'Cost Transcendence', desc: 'Costs are capped at 10% of current mana', milestone: 5, effect: { type: 'special', specialId: 'costTranscendence', specialDesc: 'Capped costs' } },
{ id: 'fm_t4_l5_omega', name: 'Omega Focus', desc: 'All sources of mana cost reduction are doubled', milestone: 5, effect: { type: 'special', specialId: 'omegaFocus', specialDesc: 'Doubled reduction' } },
{ id: 'fm_t4_l10_free', name: 'Mostly Free+', desc: '90% of studies are free', milestone: 10, effect: { type: 'special', specialId: 'mostlyFreePlus', specialDesc: 'Almost all free' } },
{ id: 'fm_t4_l10_zero', name: 'Zero Cost', desc: 'All studies cost 0', milestone: 10, effect: { type: 'special', specialId: 'zeroCost', specialDesc: 'All free' } },
{ id: 'fm_t4_l10_profit', name: 'Study Profit', desc: 'Gain mana when studying', milestone: 10, effect: { type: 'special', specialId: 'studyProfit', specialDesc: 'Study gives mana' } },
{ id: 'fm_t4_l10_eternal', name: 'Eternal Focus', desc: 'Mind never tires - no cost ever', milestone: 10, effect: { type: 'special', specialId: 'eternalFocus', specialDesc: 'Never tire' } },
],
},
{
tier: 5,
skillId: 'focusedMind_t5',
name: 'Omniscient Mind',
multiplier: 10000,
upgrades: [
{ id: 'fm_t5_l5_godhood', name: 'Focus Godhood', desc: '+500% cost reduction', milestone: 5, effect: { type: 'multiplier', stat: 'costReduction', value: 6 } },
{ id: 'fm_t5_l5_all', name: 'All Free', desc: 'All studies cost nothing', milestone: 5, effect: { type: 'special', specialId: 'allFree', specialDesc: 'All free' } },
{ id: 'fm_t5_l5_source', name: 'Mana Source', desc: 'Become a source of study mana (regen while studying)', milestone: 5, effect: { type: 'special', specialId: 'manaSource', specialDesc: 'Mana source' } },
{ id: 'fm_t5_l5_transcend', name: 'Cost Transcendence', desc: 'Transcend all costs permanently', milestone: 5, effect: { type: 'special', specialId: 'costTranscendenceFinal', specialDesc: 'Transcend costs' } },
{ id: 'fm_t5_l10_perfection', name: 'Perfect Mind', desc: 'All costs are 0, always', milestone: 10, effect: { type: 'special', specialId: 'perfectMind', specialDesc: 'Zero costs' } },
{ id: 'fm_t5_l10_victory', name: 'Focus Victory', desc: 'Victory: study everything instantly', milestone: 10, effect: { type: 'special', specialId: 'focusVictory', specialDesc: 'Free victory' } },
{ id: 'fm_t5_l10_eternal', name: 'Eternal Focus', desc: 'Mind never tires, infinite capacity', milestone: 10, effect: { type: 'special', specialId: 'eternalFocusFinal', specialDesc: 'Never tire' } },
{ id: 'fm_t5_l10_origin', name: 'Origin Focus', desc: 'You are the source of focus', milestone: 10, effect: { type: 'special', specialId: 'originFocus', specialDesc: 'Focus origin' } },
],
},
],
},
elemAttune: {
baseSkillId: 'elemAttune',
tiers: [
{
tier: 1,
skillId: 'elemAttune',
name: 'Elemental Attunement',
multiplier: 1,
upgrades: [...ELEM_ATTUNE_TIER1_UPGRADES_L5, ...ELEM_ATTUNE_TIER1_UPGRADES_L10],
},
{
tier: 2,
skillId: 'elemAttune_t2',
name: 'Elemental Affinity',
multiplier: 10,
upgrades: [
{ id: 'ea_t2_l5_expand', name: 'Expanded Affinity', desc: '+50% element cap', milestone: 5, effect: { type: 'multiplier', stat: 'elementCap', value: 1.5 } },
{ id: 'ea_t2_l5_dual', name: 'Dual Elements', desc: 'Convert to 2 elements at once', milestone: 5, effect: { type: 'special', specialId: 'dualElements', specialDesc: 'Dual convert' } },
{ id: 'ea_t2_l5_stable', name: 'Stable Elements', desc: 'Elemental mana never decays', milestone: 5, effect: { type: 'special', specialId: 'stableElements', specialDesc: 'No decay' } },
{ id: 'ea_t2_l5_amplify', name: 'Element Amplify', desc: '+25% elemental damage', milestone: 5, effect: { type: 'multiplier', stat: 'elementalDamage', value: 1.25 } },
{ id: 'ea_t2_l10_mastery', name: 'Element Mastery', desc: '+200 element cap', milestone: 10, effect: { type: 'bonus', stat: 'elementCap', value: 200 } },
{ id: 'ea_t2_l10_convert', name: 'Quick Convert', desc: 'Convert 10 at a time with 10% bonus', milestone: 10, effect: { type: 'special', specialId: 'quickConvert', specialDesc: 'Bulk convert' } },
{ id: 'ea_t2_l10_harmony', name: 'Element Harmony+', desc: 'All elements work together (+10% all element damage)', milestone: 10, effect: { type: 'special', specialId: 'elementHarmonyPlus', specialDesc: 'Element synergy' } },
{ id: 'ea_t2_l10_overflow', name: 'Element Overflow', desc: 'Excess elements convert to raw mana', milestone: 10, effect: { type: 'special', specialId: 'elementOverflow', specialDesc: 'Overflow conversion' } },
],
},
{
tier: 3,
skillId: 'elemAttune_t3',
name: 'Elemental Mastery',
multiplier: 100,
upgrades: [
{ id: 'ea_t3_l5_dominator', name: 'Element Dominator', desc: '+100% element cap', milestone: 5, effect: { type: 'multiplier', stat: 'elementCap', value: 2 } },
{ id: 'ea_t3_l5_forge', name: 'Element Forge', desc: 'Craft exotic elements without recipe', milestone: 5, effect: { type: 'special', specialId: 'elementForge', specialDesc: 'Exotic crafting' } },
{ id: 'ea_t3_l5_surge', name: 'Element Surge', desc: '+50% elemental spell damage', milestone: 5, effect: { type: 'multiplier', stat: 'elementalDamage', value: 1.5 } },
{ id: 'ea_t3_l5_well', name: 'Element Well', desc: 'Elements regenerate 1 per hour', milestone: 5, effect: { type: 'special', specialId: 'elementWell', specialDesc: 'Element regen' } },
{ id: 'ea_t3_l10_transcend', name: 'Element Transcendence', desc: '+1000 element cap', milestone: 10, effect: { type: 'bonus', stat: 'elementCap', value: 1000 } },
{ id: 'ea_t3_l10_avatar', name: 'Element Avatar', desc: 'Become one with elements (immune to element weaknesses)', milestone: 10, effect: { type: 'special', specialId: 'elementAvatar', specialDesc: 'Element unity' } },
{ id: 'ea_t3_l10_chain', name: 'Element Chain', desc: 'Using one element boosts next element by 20%', milestone: 10, effect: { type: 'special', specialId: 'elementChain', specialDesc: 'Chain bonus' } },
{ id: 'ea_t3_l10_prime', name: 'Prime Elements', desc: 'Base elements give 2x capacity bonus', milestone: 10, effect: { type: 'special', specialId: 'primeElements', specialDesc: 'Prime bonus' } },
],
},
{
tier: 4,
skillId: 'elemAttune_t4',
name: 'Elemental Sovereign',
multiplier: 1000,
upgrades: [
{ id: 'ea_t4_l5_sovereign', name: 'Sovereign Elements', desc: '+200% element cap', milestone: 5, effect: { type: 'multiplier', stat: 'elementCap', value: 3 } },
{ id: 'ea_t4_l5_exotic', name: 'Exotic Mastery', desc: 'Exotic elements +100% damage', milestone: 5, effect: { type: 'special', specialId: 'exoticMastery', specialDesc: 'Exotic bonus' } },
{ id: 'ea_t4_l5_infinite', name: 'Infinite Elements', desc: 'Element cap scales with loop count', milestone: 5, effect: { type: 'special', specialId: 'infiniteElements', specialDesc: 'Loop scaling cap' } },
{ id: 'ea_t4_l5_conduit', name: 'Element Conduit', desc: 'Channel all elements at once for combined damage', milestone: 5, effect: { type: 'special', specialId: 'elementConduit', specialDesc: 'All elements' } },
{ id: 'ea_t4_l10_ascension', name: 'Element Ascension', desc: '+5000 element cap', milestone: 10, effect: { type: 'bonus', stat: 'elementCap', value: 5000 } },
{ id: 'ea_t4_l10_omega', name: 'Omega Element', desc: 'Unlock the Omega element (combines all)', milestone: 10, effect: { type: 'special', specialId: 'omegaElement', specialDesc: 'Omega unlock' } },
{ id: 'ea_t4_l10_perfect', name: 'Perfect Attunement', desc: 'All elements at max power always', milestone: 10, effect: { type: 'special', specialId: 'perfectAttunement', specialDesc: 'Perfect power' } },
{ id: 'ea_t4_l10_storm', name: 'Element Storm', desc: 'All elements attack together for 3x damage', milestone: 10, effect: { type: 'special', specialId: 'elementStorm', specialDesc: 'Combined attack' } },
],
},
{
tier: 5,
skillId: 'elemAttune_t5',
name: 'Primordial Element',
multiplier: 10000,
upgrades: [
{ id: 'ea_t5_l5_primordial', name: 'Primordial Power', desc: '+500% element cap', milestone: 5, effect: { type: 'multiplier', stat: 'elementCap', value: 6 } },
{ id: 'ea_t5_l5_omega', name: 'Omega Mastery', desc: 'Control the Omega element (all elements in one)', milestone: 5, effect: { type: 'special', specialId: 'omegaMastery', specialDesc: 'Omega control' } },
{ id: 'ea_t5_l5_origin', name: 'Element Origin', desc: 'You are the source of elements', milestone: 5, effect: { type: 'special', specialId: 'elementOrigin', specialDesc: 'Element source' } },
{ id: 'ea_t5_l5_transcend', name: 'Element Transcendence', desc: 'Transcend elemental limits', milestone: 5, effect: { type: 'special', specialId: 'elementTranscendence', specialDesc: 'Limitless' } },
{ id: 'ea_t5_l10_godhood', name: 'Element Godhood', desc: '+50000 element cap', milestone: 10, effect: { type: 'bonus', stat: 'elementCap', value: 50000 } },
{ id: 'ea_t5_l10_victory', name: 'Element Victory', desc: 'Victory: master all elements', milestone: 10, effect: { type: 'special', specialId: 'elementVictory', specialDesc: 'Element victory' } },
{ id: 'ea_t5_l10_eternal', name: 'Eternal Elements', desc: 'Elements never deplete', milestone: 10, effect: { type: 'special', specialId: 'eternalElements', specialDesc: 'Infinite elements' } },
{ id: 'ea_t5_l10_ultimate', name: 'Ultimate Element', desc: 'Create your own element', milestone: 10, effect: { type: 'special', specialId: 'ultimateElement', specialDesc: 'Custom element' } },
],
},
],
},
// ─── Enchanting Skills Evolution Paths ─────────────────────────────────────
enchanting: {
baseSkillId: 'enchanting',
tiers: [
{
tier: 1,
skillId: 'enchanting',
name: 'Enchanting',
multiplier: 1,
upgrades: [
{ id: 'ench_t1_l5_capacity', name: 'Efficient Runes', desc: '-10% enchantment capacity cost', milestone: 5, effect: { type: 'multiplier', stat: 'enchantCapacityCost', value: 0.9 } },
{ id: 'ench_t1_l5_speed', name: 'Quick Scribing', desc: '-15% design time', milestone: 5, effect: { type: 'multiplier', stat: 'designTime', value: 0.85 } },
{ id: 'ench_t1_l5_power', name: 'Potent Enchantments', desc: '+10% effect power', milestone: 5, effect: { type: 'multiplier', stat: 'enchantPower', value: 1.1 } },
{ id: 'ench_t1_l5_stable', name: 'Stable Runes', desc: 'Enchantments never degrade', milestone: 5, effect: { type: 'special', specialId: 'stableRunes', specialDesc: 'No degradation' } },
{ id: 'ench_t1_l10_master', name: 'Enchant Master', desc: '-20% capacity cost', milestone: 10, effect: { type: 'multiplier', stat: 'enchantCapacityCost', value: 0.8 } },
{ id: 'ench_t1_l10_speed', name: 'Swift Enchanter', desc: '-25% all enchant times', milestone: 10, effect: { type: 'multiplier', stat: 'enchantTime', value: 0.75 } },
{ id: 'ench_t1_l10_double', name: 'Double Enchant', desc: '10% chance to apply enchant twice', milestone: 10, effect: { type: 'special', specialId: 'doubleEnchant', specialDesc: 'Double application' } },
{ id: 'ench_t1_l10_quality', name: 'Quality Craft', desc: '+25% effect power', milestone: 10, effect: { type: 'multiplier', stat: 'enchantPower', value: 1.25 } },
],
},
{
tier: 2,
skillId: 'enchanting_t2',
name: 'Rune Master',
multiplier: 10,
upgrades: [
{ id: 'ench_t2_l5_advanced', name: 'Advanced Runes', desc: '-25% capacity cost', milestone: 5, effect: { type: 'multiplier', stat: 'enchantCapacityCost', value: 0.75 } },
{ id: 'ench_t2_l5_quick', name: 'Quick Application', desc: '-30% application time', milestone: 5, effect: { type: 'multiplier', stat: 'applicationTime', value: 0.7 } },
{ id: 'ench_t2_l5_overcharge', name: 'Overcharge', desc: 'Effects are 50% stronger', milestone: 5, effect: { type: 'multiplier', stat: 'enchantPower', value: 1.5 } },
{ id: 'ench_t2_l5_save', name: 'Design Memory', desc: 'Save 3 designs per equipment type', milestone: 5, effect: { type: 'special', specialId: 'designMemory', specialDesc: 'More designs' } },
{ id: 'ench_t2_l10_expert', name: 'Expert Enchanter', desc: '-40% all costs', milestone: 10, effect: { type: 'multiplier', stat: 'enchantCapacityCost', value: 0.6 } },
{ id: 'ench_t2_l10_rapid', name: 'Rapid Enchant', desc: '-50% all times', milestone: 10, effect: { type: 'multiplier', stat: 'enchantTime', value: 0.5 } },
{ id: 'ench_t2_l10_triple', name: 'Triple Chance', desc: '15% chance for triple effect', milestone: 10, effect: { type: 'special', specialId: 'tripleEnchant', specialDesc: 'Triple chance' } },
{ id: 'ench_t2_l10_essence', name: 'Essence Infusion', desc: 'Enchantments grant bonus mana', milestone: 10, effect: { type: 'special', specialId: 'essenceInfusion', specialDesc: 'Mana from enchants' } },
],
},
{
tier: 3,
skillId: 'enchanting_t3',
name: 'Arcane Forgemaster',
multiplier: 100,
upgrades: [
{ id: 'ench_t3_l5_efficient', name: 'Efficient Runes', desc: '-50% capacity cost', milestone: 5, effect: { type: 'multiplier', stat: 'enchantCapacityCost', value: 0.5 } },
{ id: 'ench_t3_l5_instant', name: 'Instant Prep', desc: 'Preparation takes 1 hour', milestone: 5, effect: { type: 'special', specialId: 'instantPrep', specialDesc: 'Fast prep' } },
{ id: 'ench_t3_l5_mighty', name: 'Mighty Enchantments', desc: '+100% effect power', milestone: 5, effect: { type: 'multiplier', stat: 'enchantPower', value: 2 } },
{ id: 'ench_t3_l5_transfer', name: 'Enchant Transfer', desc: 'Move enchantments between items', milestone: 5, effect: { type: 'special', specialId: 'enchantTransfer', specialDesc: 'Transfer enchants' } },
{ id: 'ench_t3_l10_master', name: 'Forge Master', desc: '-60% capacity cost', milestone: 10, effect: { type: 'multiplier', stat: 'enchantCapacityCost', value: 0.4 } },
{ id: 'ench_t3_l10_spellweave', name: 'Spellweaving', desc: 'Combine spell effects', milestone: 10, effect: { type: 'special', specialId: 'spellweaving', specialDesc: 'Combine spells' } },
{ id: 'ench_t3_l10_legendary', name: 'Legendary Enchanter', desc: 'Create legendary tier items', milestone: 10, effect: { type: 'special', specialId: 'legendaryEnchanter', specialDesc: 'Legendary tier' } },
{ id: 'ench_t3_l10_eternal', name: 'Eternal Enchantments', desc: 'Enchantments last forever', milestone: 10, effect: { type: 'special', specialId: 'eternalEnchantments', specialDesc: 'Permanent enchants' } },
],
},
{
tier: 4,
skillId: 'enchanting_t4',
name: 'Void Enchanter',
multiplier: 1000,
upgrades: [
{ id: 'ench_t4_l5_void', name: 'Void Runes', desc: '-70% capacity cost', milestone: 5, effect: { type: 'multiplier', stat: 'enchantCapacityCost', value: 0.3 } },
{ id: 'ench_t4_l5_instant', name: 'Instant Design', desc: 'Designs complete instantly', milestone: 5, effect: { type: 'special', specialId: 'instantDesign', specialDesc: 'Instant design' } },
{ id: 'ench_t4_l5_power', name: 'Void Power', desc: '+200% effect power', milestone: 5, effect: { type: 'multiplier', stat: 'enchantPower', value: 3 } },
{ id: 'ench_t4_l5_copy', name: 'Enchant Copy', desc: 'Copy enchantments from other items', milestone: 5, effect: { type: 'special', specialId: 'enchantCopy', specialDesc: 'Copy enchants' } },
{ id: 'ench_t4_l10_transcend', name: 'Transcendent Enchanting', desc: '-80% capacity cost', milestone: 10, effect: { type: 'multiplier', stat: 'enchantCapacityCost', value: 0.2 } },
{ id: 'ench_t4_l10_mythic', name: 'Mythic Crafter', desc: 'Create mythic tier items', milestone: 10, effect: { type: 'special', specialId: 'mythicCrafter', specialDesc: 'Mythic tier' } },
{ id: 'ench_t4_l10_soulbind', name: 'Soulbinding', desc: 'Enchantments persist through loops', milestone: 10, effect: { type: 'special', specialId: 'soulbinding', specialDesc: 'Loop persistence' } },
{ id: 'ench_t4_l10_overflow', name: 'Capacity Overflow', desc: 'Equipment can exceed capacity limits', milestone: 10, effect: { type: 'special', specialId: 'capacityOverflow', specialDesc: 'Overfill capacity' } },
],
},
{
tier: 5,
skillId: 'enchanting_t5',
name: 'Enchantment God',
multiplier: 10000,
upgrades: [
{ id: 'ench_t5_l5_godhood', name: 'Enchant Godhood', desc: '-90% capacity cost', milestone: 5, effect: { type: 'multiplier', stat: 'enchantCapacityCost', value: 0.1 } },
{ id: 'ench_t5_l5_instant', name: 'Instant All', desc: 'All enchanting is instant', milestone: 5, effect: { type: 'special', specialId: 'instantAllEnchant', specialDesc: 'All instant' } },
{ id: 'ench_t5_l5_ultimate', name: 'Ultimate Power', desc: '+500% effect power', milestone: 5, effect: { type: 'multiplier', stat: 'enchantPower', value: 6 } },
{ id: 'ench_t5_l5_create', name: 'Create Effects', desc: 'Design custom enchantment effects', milestone: 5, effect: { type: 'special', specialId: 'createEffects', specialDesc: 'Custom effects' } },
{ id: 'ench_t5_l10_perfection', name: 'Perfect Enchanting', desc: 'All costs are 0', milestone: 10, effect: { type: 'special', specialId: 'perfectEnchanting', specialDesc: 'Zero costs' } },
{ id: 'ench_t5_l10_victory', name: 'Enchant Victory', desc: 'Victory: enchant anything', milestone: 10, effect: { type: 'special', specialId: 'enchantVictory', specialDesc: 'Enchant victory' } },
{ id: 'ench_t5_l10_infinite', name: 'Infinite Capacity', desc: 'No capacity limits on equipment', milestone: 10, effect: { type: 'special', specialId: 'infiniteCapacity', specialDesc: 'Infinite capacity' } },
{ id: 'ench_t5_l10_origin', name: 'Enchantment Origin', desc: 'You are the source of all enchantments', milestone: 10, effect: { type: 'special', specialId: 'enchantmentOrigin', specialDesc: 'Enchant source' } },
],
},
],
},
efficientEnchant: {
baseSkillId: 'efficientEnchant',
tiers: [
{
tier: 1,
skillId: 'efficientEnchant',
name: 'Efficient Enchant',
multiplier: 1,
upgrades: [
{ id: 'ee_t1_l5_extra', name: 'Extra Efficiency', desc: '-10% additional capacity cost', milestone: 5, effect: { type: 'bonus', stat: 'enchantEfficiency', value: 0.1 } },
{ id: 'ee_t1_l5_stacking', name: 'Stacking Efficiency', desc: 'Each enchant is 5% cheaper', milestone: 5, effect: { type: 'special', specialId: 'stackingEfficiency', specialDesc: 'Stacking discount' } },
{ id: 'ee_t1_l10_master', name: 'Efficiency Master', desc: '-20% capacity cost', milestone: 10, effect: { type: 'bonus', stat: 'enchantEfficiency', value: 0.2 } },
{ id: 'ee_t1_l10_overflow', name: 'Efficient Overflow', desc: 'Spare capacity becomes bonus power', milestone: 10, effect: { type: 'special', specialId: 'efficientOverflow', specialDesc: 'Capacity to power' } },
],
},
],
},
disenchanting: {
baseSkillId: 'disenchanting',
tiers: [
{
tier: 1,
skillId: 'disenchanting',
name: 'Disenchanting',
multiplier: 1,
upgrades: [
{ id: 'dis_t1_l5_recover', name: 'Better Recovery', desc: '+15% mana recovery', milestone: 5, effect: { type: 'bonus', stat: 'disenchantRecovery', value: 0.15 } },
{ id: 'dis_t1_l5_partial', name: 'Partial Disenchant', desc: 'Remove individual enchantments', milestone: 5, effect: { type: 'special', specialId: 'partialDisenchant', specialDesc: 'Selective removal' } },
{ id: 'dis_t1_l10_full', name: 'Full Recovery', desc: '+30% mana recovery', milestone: 10, effect: { type: 'bonus', stat: 'disenchantRecovery', value: 0.3 } },
{ id: 'dis_t1_l10_salvage', name: 'Effect Salvage', desc: 'Save removed effects as scrolls', milestone: 10, effect: { type: 'special', specialId: 'effectSalvage', specialDesc: 'Save as scroll' } },
],
},
],
},
enchantSpeed: {
baseSkillId: 'enchantSpeed',
tiers: [
{
tier: 1,
skillId: 'enchantSpeed',
name: 'Enchant Speed',
multiplier: 1,
upgrades: [
{ id: 'es_t1_l5_haste', name: 'Enchant Haste', desc: '-15% all enchant times', milestone: 5, effect: { type: 'multiplier', stat: 'enchantTime', value: 0.85 } },
{ id: 'es_t1_l5_parallel', name: 'Parallel Enchant', desc: 'Work on 2 items at once', milestone: 5, effect: { type: 'special', specialId: 'parallelEnchant', specialDesc: 'Dual work' } },
{ id: 'es_t1_l10_swift', name: 'Swift Enchanter', desc: '-30% all enchant times', milestone: 10, effect: { type: 'multiplier', stat: 'enchantTime', value: 0.7 } },
{ id: 'es_t1_l10_instant', name: 'Quick Design', desc: 'Designs complete in half time', milestone: 10, effect: { type: 'special', specialId: 'quickDesign', specialDesc: 'Fast design' } },
],
},
],
},
};
// ─── Get Upgrades for Skill at Milestone ──────────────────────────────────────────
export function getUpgradesForSkillAtMilestone(
skillId: string,
milestone: 5 | 10,
skillTiers: Record<string, number> = {}
): SkillUpgradeChoice[] {
// Find the base skill and current tier
let baseSkillId = skillId;
let currentTier = skillTiers[skillId] || 1;
// Check if this is a tier skill (e.g., 'manaWell_t2')
if (skillId.includes('_t')) {
const parts = skillId.split('_t');
baseSkillId = parts[0];
currentTier = parseInt(parts[1]) || 1;
}
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
if (!path) return [];
const tierDef = path.tiers.find(t => t.tier === currentTier);
if (!tierDef) return [];
return tierDef.upgrades.filter(u => u.milestone === milestone);
}
// ─── Get Next Tier Skill ─────────────────────────────────────────────────────────
export function getNextTierSkill(skillId: string): string | null {
let baseSkillId = skillId;
let currentTier = 1;
if (skillId.includes('_t')) {
const parts = skillId.split('_t');
baseSkillId = parts[0];
currentTier = parseInt(parts[1]) || 1;
}
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
if (!path) return null;
const nextTier = path.tiers.find(t => t.tier === currentTier + 1);
return nextTier?.skillId || null;
}
// ─── Get Tier Multiplier ─────────────────────────────────────────────────────────
export function getTierMultiplier(skillId: string): number {
let baseSkillId = skillId;
let currentTier = 1;
if (skillId.includes('_t')) {
const parts = skillId.split('_t');
baseSkillId = parts[0];
currentTier = parseInt(parts[1]) || 1;
}
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
if (!path) return 1;
const tierDef = path.tiers.find(t => t.tier === currentTier);
return tierDef?.multiplier || 1;
}
// ─── Generate Tier Skills Dynamically ─────────────────────────────────────────────
export function generateTierSkillDef(baseSkillId: string, tier: number): SkillDef | null {
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
if (!path) return null;
const tierDef = path.tiers.find(t => t.tier === tier);
if (!tierDef) return null;
const baseDef = path.tiers[0];
return {
name: tierDef.name,
desc: `Tier ${tier} evolution - ${tierDef.multiplier}x base effect`,
cat: getCategoryForBaseSkill(baseSkillId),
max: 10,
base: 100 * tier, // Cost scales with tier
studyTime: 4 * tier, // Study time scales with tier
tier: tier,
baseSkill: baseSkillId,
tierMultiplier: tierDef.multiplier,
};
}
function getCategoryForBaseSkill(baseSkillId: string): string {
const categoryMap: Record<string, string> = {
manaWell: 'mana',
manaFlow: 'mana',
combatTrain: 'combat',
quickLearner: 'study',
focusedMind: 'study',
elemAttune: 'mana',
};
return categoryMap[baseSkillId] || 'study';
}

1900
src/lib/game/store.ts Executable file

File diff suppressed because it is too large Load Diff

180
src/lib/game/study-slice.ts Executable file
View File

@@ -0,0 +1,180 @@
// ─── Study Slice ─────────────────────────────────────────────────────────────
// Actions for studying skills and spells
import type { GameState } from './types';
import { SKILLS_DEF, SPELLS_DEF, getStudyCostMultiplier } from './constants';
// ─── Study Actions Interface ──────────────────────────────────────────────────
export interface StudyActions {
startStudyingSkill: (skillId: string) => void;
startStudyingSpell: (spellId: string) => void;
cancelStudy: () => void;
startParallelStudySkill: (skillId: string) => void;
cancelParallelStudy: () => void;
}
// ─── Study Slice Factory ──────────────────────────────────────────────────────
export function createStudySlice(
set: (partial: Partial<GameState> | ((state: GameState) => Partial<GameState>)) => void,
get: () => GameState
): StudyActions {
return {
// Start studying a skill - mana is deducted per hour, not upfront
startStudyingSkill: (skillId: string) => {
const state = get();
const sk = SKILLS_DEF[skillId];
if (!sk) return;
const currentLevel = state.skills[skillId] || 0;
if (currentLevel >= sk.max) return;
// Check prerequisites
if (sk.req) {
for (const [r, rl] of Object.entries(sk.req)) {
if ((state.skills[r] || 0) < rl) return;
}
}
// Calculate total mana cost and cost per hour
const costMult = getStudyCostMultiplier(state.skills);
const totalCost = Math.floor(sk.base * (currentLevel + 1) * costMult);
const manaCostPerHour = Math.ceil(totalCost / sk.studyTime);
// Must have at least 1 hour worth of mana to start
if (state.rawMana < manaCostPerHour) return;
// Start studying (no upfront cost - mana is deducted per hour during study)
set({
currentAction: 'study',
currentStudyTarget: {
type: 'skill',
id: skillId,
progress: state.skillProgress[skillId] || 0,
required: sk.studyTime,
manaCostPerHour: manaCostPerHour,
totalCost: totalCost,
},
log: [`📚 Started studying ${sk.name} (${manaCostPerHour} mana/hr)...`, ...state.log.slice(0, 49)],
});
},
// Start studying a spell
startStudyingSpell: (spellId: string) => {
const state = get();
const sp = SPELLS_DEF[spellId];
if (!sp || state.spells[spellId]?.learned) return;
// Calculate total mana cost and cost per hour
const costMult = getStudyCostMultiplier(state.skills);
const totalCost = Math.floor(sp.unlock * costMult);
const studyTime = sp.studyTime || (sp.tier * 4);
const manaCostPerHour = Math.ceil(totalCost / studyTime);
// Must have at least 1 hour worth of mana to start
if (state.rawMana < manaCostPerHour) return;
// Start studying (no upfront cost - mana is deducted per hour during study)
set({
currentAction: 'study',
currentStudyTarget: {
type: 'spell',
id: spellId,
progress: state.spells[spellId]?.studyProgress || 0,
required: studyTime,
manaCostPerHour: manaCostPerHour,
totalCost: totalCost,
},
spells: {
...state.spells,
[spellId]: { ...(state.spells[spellId] || { learned: false, level: 0 }), studyProgress: state.spells[spellId]?.studyProgress || 0 },
},
log: [`📚 Started studying ${sp.name} (${manaCostPerHour} mana/hr)...`, ...state.log.slice(0, 49)],
});
},
// Cancel current study (saves progress)
cancelStudy: () => {
const state = get();
if (!state.currentStudyTarget) return;
// Knowledge retention bonus
const retentionBonus = 1 + (state.skills.knowledgeRetention || 0) * 0.2;
const savedProgress = Math.min(
state.currentStudyTarget.progress,
state.currentStudyTarget.required * retentionBonus
);
// Save progress
if (state.currentStudyTarget.type === 'skill') {
set({
currentStudyTarget: null,
currentAction: 'meditate',
skillProgress: {
...state.skillProgress,
[state.currentStudyTarget.id]: savedProgress,
},
log: [`📖 Study interrupted. Progress saved.`, ...state.log.slice(0, 49)],
});
} else if (state.currentStudyTarget.type === 'spell') {
set({
currentStudyTarget: null,
currentAction: 'meditate',
spells: {
...state.spells,
[state.currentStudyTarget.id]: {
...(state.spells[state.currentStudyTarget.id] || { learned: false, level: 0 }),
studyProgress: savedProgress,
},
},
log: [`📖 Study interrupted. Progress saved.`, ...state.log.slice(0, 49)],
});
}
},
// Start parallel study of a skill (requires Parallel Mind upgrade)
startParallelStudySkill: (skillId: string) => {
const state = get();
if (state.parallelStudyTarget) return; // Already have parallel study
if (!state.currentStudyTarget) return; // Need primary study
const sk = SKILLS_DEF[skillId];
if (!sk) return;
const currentLevel = state.skills[skillId] || 0;
if (currentLevel >= sk.max) return;
// Can't study same thing in parallel
if (state.currentStudyTarget.id === skillId) return;
// Calculate mana cost for parallel study
const costMult = getStudyCostMultiplier(state.skills);
const totalCost = Math.floor(sk.base * (currentLevel + 1) * costMult);
const manaCostPerHour = Math.ceil(totalCost / sk.studyTime);
set({
parallelStudyTarget: {
type: 'skill',
id: skillId,
progress: state.skillProgress[skillId] || 0,
required: sk.studyTime,
manaCostPerHour: Math.ceil(manaCostPerHour / 2), // Half speed = half mana cost per tick
totalCost: totalCost,
},
log: [`📚 Started parallel study of ${sk.name}... (50% speed)`, ...state.log.slice(0, 49)],
});
},
// Cancel parallel study
cancelParallelStudy: () => {
set((state) => {
if (!state.parallelStudyTarget) return state;
return {
parallelStudyTarget: null,
log: ['📖 Parallel study cancelled.', ...state.log.slice(0, 49)],
};
});
},
};
}

448
src/lib/game/types.ts Executable file
View File

@@ -0,0 +1,448 @@
// ─── Game Types ───────────────────────────────────────────────────────────────
export type ElementCategory = 'base' | 'utility' | 'composite' | 'exotic';
// Attunement body slots
export type AttunementSlot = 'rightHand' | 'leftHand' | 'head' | 'back' | 'chest' | 'leftLeg' | 'rightLeg';
// Attunement definition
export interface AttunementDef {
id: string;
name: string;
desc: string;
slot: AttunementSlot;
icon: string;
color: string;
primaryManaType?: string; // Primary mana type this attunement generates (null for Invoker)
rawManaRegen: number; // Raw mana regeneration per hour granted by this attunement
conversionRate: number; // Raw mana converted to primary type per hour
unlocked: boolean; // Whether this is unlocked by default
unlockCondition?: string; // Description of how to unlock (for future challenges)
capabilities: string[]; // What this attunement enables (e.g., 'enchanting', 'pacts', 'golemCrafting')
skillCategories: string[]; // Skill categories this attunement provides access to
}
// Attunement instance state (tracks player's attunements)
export interface AttunementState {
id: string;
active: boolean; // Whether this attunement is currently active
level: number; // Attunement level (for future progression)
experience: number; // Progress toward next level
}
export interface ElementDef {
name: string;
sym: string;
color: string;
glow: string;
cat: ElementCategory;
recipe?: string[];
}
export interface ElementState {
current: number;
max: number;
unlocked: boolean;
}
// Boon types that guardians can grant
export interface GuardianBoon {
type: 'maxMana' | 'manaRegen' | 'castingSpeed' | 'elementalDamage' | 'rawDamage' |
'critChance' | 'critDamage' | 'spellEfficiency' | 'manaGain' | 'insightGain' |
'studySpeed' | 'prestigeInsight';
value: number;
desc: string;
}
export interface GuardianDef {
name: string;
element: string;
hp: number;
pact: number; // Pact multiplier when signed
color: string;
boons: GuardianBoon[]; // Bonuses granted when pact is signed
pactCost: number; // Mana cost to perform pact ritual
pactTime: number; // Hours required for pact ritual
uniquePerk: string; // Description of unique perk
}
// Spell cost can be raw mana or elemental mana
export interface SpellCost {
type: 'raw' | 'element'; // 'raw' for raw mana, 'element' for specific elemental mana
element?: string; // Required if type is 'element'
amount: number; // Amount of mana required
}
export interface SpellDef {
name: string;
elem: string; // Element type for damage calculations
dmg: number;
cost: SpellCost; // Changed from number to SpellCost object
tier: number;
unlock: number; // Mana cost to start studying
studyTime?: number; // Hours needed to study (optional, defaults based on tier)
castSpeed?: number; // Casts per hour (default 1, higher = faster)
desc?: string; // Optional spell description
effects?: SpellEffect[]; // Optional special effects
}
export interface SpellEffect {
type: 'lifesteal' | 'burn' | 'freeze' | 'stun' | 'pierce' | 'multicast' | 'shield' | 'buff';
value: number; // Effect potency
duration?: number; // Duration in hours for timed effects
}
export interface SpellState {
learned: boolean;
level: number;
studyProgress?: number; // Hours studied so far (for in-progress spells)
}
export interface SkillDef {
name: string;
desc: string;
cat: string;
attunement?: string; // Which attunement this skill belongs to (null = core)
max: number;
base: number; // Mana cost to start studying
req?: Record<string, number>;
studyTime: number; // Hours needed to study
level?: number; // Current level (optional, for UI display)
tier?: number; // Skill tier (1-5)
tierUp?: string; // Skill ID this evolves into at max level
baseSkill?: string; // Original skill ID this evolved from
tierMultiplier?: number; // Multiplier for each tier (default 2)
}
// Skill upgrade choices at milestones (level 5 and level 10)
export interface SkillUpgradeDef {
id: string;
name: string;
desc: string;
skillId: string; // Which skill this upgrade belongs to
milestone: 5 | 10; // Level at which this upgrade is available
effect: SkillUpgradeEffect;
}
export interface SkillUpgradeEffect {
type: 'multiplier' | 'bonus' | 'special';
stat?: string; // Stat to modify
value?: number; // Multiplier or bonus value
specialId?: string; // Special effect identifier
specialDesc?: string; // Description of special effect
}
// Skill evolution system
export interface SkillEvolutionPath {
baseSkillId: string; // Starting skill ID
tiers: SkillTierDef[]; // 5 tiers of evolution
}
export interface SkillTierDef {
tier: number;
skillId: string; // Skill ID for this tier
name: string;
multiplier: number; // Base effect multiplier
upgrades: SkillUpgradeChoice[]; // 4 upgrades available at each milestone
}
export interface SkillUpgradeChoice {
id: string;
name: string;
desc: string;
milestone: 5 | 10; // Level at which this upgrade is available
effect: SkillUpgradeEffect;
}
export interface PrestigeDef {
name: string;
desc: string;
max: number;
cost: number;
}
// Legacy EquipmentDef for backward compatibility
export interface EquipmentDef {
id: string;
name: string;
slot: 'mainHand' | 'offHand' | 'head' | 'body' | 'hands' | 'accessory';
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary' | 'mythic';
stats: Record<string, number>;
durability: number;
maxDurability: number;
element?: string;
}
// Equipment Instance (actual equipped item with enchantments)
export interface EquipmentInstance {
instanceId: string; // Unique ID for this specific item
typeId: string; // Reference to EquipmentType (e.g., 'basicStaff')
name: string; // Display name (defaults to type name)
enchantments: AppliedEnchantment[];
usedCapacity: number; // Currently used capacity
totalCapacity: number; // Base capacity + bonuses
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary' | 'mythic';
quality: number; // 0-100, affects capacity efficiency
}
export interface AppliedEnchantment {
effectId: string; // Reference to EnchantmentEffectDef
stacks: number; // Number of times this effect is applied
actualCost: number; // Actual capacity cost (after efficiency)
}
// Enchantment Design (saved design for later application)
export interface EnchantmentDesign {
id: string;
name: string;
equipmentType: string; // Which equipment type this is designed for
effects: DesignEffect[];
totalCapacityUsed: number;
designTime: number; // Hours required to design
created: number; // Timestamp
}
export interface DesignEffect {
effectId: string;
stacks: number;
capacityCost: number;
}
// Crafting Progress States
export interface DesignProgress {
designId: string;
progress: number; // Hours spent designing
required: number; // Total hours needed
}
export interface PreparationProgress {
equipmentInstanceId: string;
progress: number; // Hours spent preparing
required: number; // Total hours needed
manaCostPaid: number; // Mana cost already paid
}
export interface ApplicationProgress {
equipmentInstanceId: string;
designId: string;
progress: number; // Hours spent applying
required: number; // Total hours needed
manaPerHour: number; // Mana cost per hour
paused: boolean;
manaSpent: number; // Total mana spent so far
}
// Equipment crafting progress (from blueprints)
export interface EquipmentCraftingProgress {
blueprintId: string;
equipmentTypeId: string;
progress: number; // Hours spent crafting
required: number; // Total hours needed
manaSpent: number; // Total mana spent so far
}
// Equipment spell state (for multi-spell casting)
export interface EquipmentSpellState {
spellId: string;
sourceEquipment: string; // Equipment instance ID
castProgress: number; // 0-1 progress toward next cast
}
export interface BlueprintDef {
id: string;
name: string;
tier: number;
slot: string;
stats: Record<string, number>;
studyTime: number;
craftTime: number;
craftCost: number;
discovered: boolean;
learned: boolean;
}
// Loot inventory for materials and blueprints
export interface LootInventory {
materials: Record<string, number>; // materialId -> count
blueprints: string[]; // blueprint IDs discovered
}
// Achievement definitions
export interface AchievementDef {
id: string;
name: string;
desc: string;
category: string;
requirement: {
type: string;
value: number;
subType?: string;
};
reward: {
insight?: number;
manaBonus?: number;
damageBonus?: number;
regenBonus?: number;
title?: string;
unlockEffect?: string;
};
hidden?: boolean;
}
// Achievement state tracks unlocked achievements and progress
export interface AchievementState {
unlocked: string[]; // IDs of unlocked achievements
progress: Record<string, number>; // Progress toward achievement requirements
}
export type GameAction = 'meditate' | 'climb' | 'study' | 'craft' | 'repair' | 'convert' | 'design' | 'prepare' | 'enchant';
export interface ScheduleBlock {
id: string;
action: GameAction;
startHour: number;
endHour: number;
enabled: boolean;
target?: string; // spell id, blueprint id, skill id, element id
}
export interface StudyTarget {
type: 'skill' | 'spell' | 'blueprint';
id: string;
progress: number; // Hours studied
required: number; // Total hours needed
}
// Combo state for combat
export interface ComboState {
count: number; // Current combo hits
maxCombo: number; // Highest combo this session
multiplier: number; // Current damage multiplier
elementChain: string[]; // Last 3 elements used
decayTimer: number; // Hours until decay starts
}
export interface GameState {
// Time
day: number;
hour: number;
loopCount: number;
gameOver: boolean;
victory: boolean;
paused: boolean;
// Raw Mana
rawMana: number;
meditateTicks: number;
totalManaGathered: number;
// Attunements (class-like system)
attunements: Record<string, AttunementState>; // attunement id -> state
// Elements
elements: Record<string, ElementState>;
// Spire
currentFloor: number;
floorHP: number;
floorMaxHP: number;
maxFloorReached: number;
signedPacts: number[];
activeSpell: string;
currentAction: GameAction;
castProgress: number; // Progress towards next spell cast (0-1)
combo: ComboState; // Combat combo tracking
// Spells
spells: Record<string, SpellState>;
// Skills
skills: Record<string, number>;
skillProgress: Record<string, number>; // Saved study progress for skills
skillUpgrades: Record<string, string[]>; // Selected upgrade IDs per skill
skillTiers: Record<string, number>; // Current tier for each base skill
// Equipment System (new instance-based system)
equippedInstances: Record<string, string | null>; // slot -> instanceId
equipmentInstances: Record<string, EquipmentInstance>; // instanceId -> instance
enchantmentDesigns: EnchantmentDesign[]; // Saved enchantment designs
// Crafting Progress
designProgress: DesignProgress | null;
preparationProgress: PreparationProgress | null;
applicationProgress: ApplicationProgress | null;
equipmentCraftingProgress: EquipmentCraftingProgress | null;
// Unlocked enchantment effects for designing
unlockedEffects: string[]; // Effect IDs that have been researched
// Equipment spell states for multi-casting
equipmentSpellStates: EquipmentSpellState[];
// Legacy Equipment (for backward compatibility)
equipment: Record<string, EquipmentDef | null>;
inventory: EquipmentDef[];
// Blueprints
blueprints: Record<string, BlueprintDef>;
// Loot Inventory
lootInventory: LootInventory;
// Schedule
schedule: ScheduleBlock[];
autoSchedule: boolean;
studyQueue: string[];
craftQueue: string[];
// Current Study Target
currentStudyTarget: StudyTarget | null;
// Parallel Study Target (for Parallel Mind milestone upgrade)
parallelStudyTarget: StudyTarget | null;
// Achievements
achievements: AchievementState;
// Stats tracking
totalSpellsCast: number;
totalDamageDealt: number;
totalCraftsCompleted: number;
// Prestige
insight: number;
totalInsight: number;
prestigeUpgrades: Record<string, number>;
memorySlots: number;
memories: string[];
// Incursion
incursionStrength: number;
containmentWards: number;
// Log
log: string[];
// Loop insight (earned at end of current loop)
loopInsight: number;
}
// Action types for the store
export type GameActionType =
| { type: 'TICK' }
| { type: 'GATHER_MANA' }
| { type: 'SET_ACTION'; action: GameAction }
| { type: 'SET_SPELL'; spellId: string }
| { type: 'LEARN_SPELL'; spellId: string }
| { type: 'START_STUDYING_SKILL'; skillId: string }
| { type: 'START_STUDYING_SPELL'; spellId: string }
| { type: 'CANCEL_STUDY' }
| { type: 'CONVERT_MANA'; element: string; amount: number }
| { type: 'UNLOCK_ELEMENT'; element: string }
| { type: 'CRAFT_COMPOSITE'; target: string }
| { type: 'DO_PRESTIGE'; id: string }
| { type: 'START_NEW_LOOP' }
| { type: 'TOGGLE_PAUSE' }
| { type: 'RESET_GAME' }
| { type: 'SELECT_SKILL_UPGRADE'; skillId: string; upgradeId: string }
| { type: 'TIER_UP_SKILL'; skillId: string };

362
src/lib/game/upgrade-effects.ts Executable file
View File

@@ -0,0 +1,362 @@
// ─── Upgrade Effect System ─────────────────────────────────────────────────────
// This module handles applying skill upgrade effects to game stats
import type { SkillUpgradeChoice, SkillUpgradeEffect } from './types';
import { getUpgradesForSkillAtMilestone, SKILL_EVOLUTION_PATHS } from './skill-evolution';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface ActiveUpgradeEffect {
upgradeId: string;
skillId: string;
milestone: 5 | 10;
effect: SkillUpgradeEffect;
name: string;
desc: string;
}
export interface ComputedEffects {
// Mana effects
maxManaMultiplier: number;
maxManaBonus: number;
regenMultiplier: number;
regenBonus: number;
clickManaMultiplier: number;
clickManaBonus: number;
meditationEfficiency: number;
spellCostMultiplier: number;
conversionEfficiency: number;
// Combat effects
baseDamageMultiplier: number;
baseDamageBonus: number;
attackSpeedMultiplier: number;
critChanceBonus: number;
critDamageMultiplier: number;
elementalDamageMultiplier: number;
// Study effects
studySpeedMultiplier: number;
studyCostMultiplier: number;
progressRetention: number;
instantStudyChance: number;
freeStudyChance: number;
// Element effects
elementCapMultiplier: number;
elementCapBonus: number;
conversionCostMultiplier: number;
doubleCraftChance: number;
// Special values
permanentRegenBonus: number;
// Special effect flags (for game logic to check)
specials: Set<string>;
// All active upgrades for display
activeUpgrades: ActiveUpgradeEffect[];
}
// ─── Special Effect IDs ────────────────────────────────────────────────────────
// These are the IDs used in the 'specialId' field of special effects
export const SPECIAL_EFFECTS = {
// Mana Flow special effects
MANA_CASCADE: 'manaCascade', // +0.1 regen per 100 max mana
STEADY_STREAM: 'steadyStream', // Regen immune to incursion
MANA_TORRENT: 'manaTorrent', // +50% regen when above 75% mana
FLOW_SURGE: 'flowSurge', // Clicks restore 2x regen for 1 hour
MANA_OVERFLOW: 'manaOverflow', // Raw mana can exceed max by 20%
// Mana Well special effects
DESPERATE_WELLS: 'desperateWells', // +50% regen when below 25% mana
MANA_ECHO: 'manaEcho', // 10% chance double mana from clicks
EMERGENCY_RESERVE: 'emergencyReserve', // Keep 10% mana on new loop
// Combat special effects
FIRST_STRIKE: 'firstStrike', // +15% damage on first attack each floor
OVERPOWER: 'overpower', // +50% damage when mana above 80%
BERSERKER: 'berserker', // +50% damage when below 50% mana
COMBO_MASTER: 'comboMaster', // Every 5th attack deals 3x damage
ADRENALINE_RUSH: 'adrenalineRush', // Defeating enemy restores 5% mana
// Study special effects
QUICK_GRASP: 'quickGrasp', // 5% chance double study progress per hour
DEEP_CONCENTRATION: 'deepConcentration', // +20% study speed when mana > 90%
QUICK_MASTERY: 'quickMastery', // -20% study time for final 3 levels
PARALLEL_STUDY: 'parallelStudy', // Study 2 things at 50% speed
STUDY_MOMENTUM: 'studyMomentum', // +5% study speed per consecutive hour
KNOWLEDGE_ECHO: 'knowledgeEcho', // 10% chance instant study
KNOWLEDGE_TRANSFER: 'knowledgeTransfer', // New skills start at 10% progress
MENTAL_CLARITY: 'mentalClarity', // +10% study speed when mana > 75%
STUDY_REFUND: 'studyRefund', // 25% mana back on study complete
DEEP_UNDERSTANDING: 'deepUnderstanding', // +10% bonus from all skill levels
STUDY_RUSH: 'studyRush', // First hour of study is 2x speed
CHAIN_STUDY: 'chainStudy', // -5% cost per maxed skill
// Element special effects
ELEMENTAL_AFFINITY: 'elementalAffinity', // New elements start with 10 capacity
EXOTIC_MASTERY: 'exoticMastery', // +20% exotic element damage
ELEMENTAL_RESONANCE: 'elementalResonance', // Using element spells restores 1 of that element
MANA_CONDUIT: 'manaConduit', // Meditation regenerates elemental mana
} as const;
// ─── Upgrade Definition Cache ─────────────────────────────────────────────────
// Cache all upgrades by ID for quick lookup
const upgradeDefinitionsById: Map<string, SkillUpgradeChoice> = new Map();
// Build the cache on first access
function buildUpgradeCache(): void {
if (upgradeDefinitionsById.size > 0) return;
for (const [baseSkillId, path] of Object.entries(SKILL_EVOLUTION_PATHS)) {
for (const tierDef of path.tiers) {
for (const upgrade of tierDef.upgrades) {
upgradeDefinitionsById.set(upgrade.id, upgrade);
}
}
}
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Get all selected upgrades with their full effect definitions
*/
export function getActiveUpgrades(
skillUpgrades: Record<string, string[]>,
skillTiers: Record<string, number>
): ActiveUpgradeEffect[] {
buildUpgradeCache();
const result: ActiveUpgradeEffect[] = [];
for (const [skillId, upgradeIds] of Object.entries(skillUpgrades)) {
for (const upgradeId of upgradeIds) {
const upgradeDef = upgradeDefinitionsById.get(upgradeId);
if (upgradeDef) {
result.push({
upgradeId,
skillId,
milestone: upgradeDef.milestone,
effect: upgradeDef.effect,
name: upgradeDef.name,
desc: upgradeDef.desc,
});
}
}
}
return result;
}
/**
* Compute all active effects from selected upgrades
*/
export function computeEffects(
skillUpgrades: Record<string, string[]>,
skillTiers: Record<string, number>
): ComputedEffects {
const activeUpgrades = getActiveUpgrades(skillUpgrades, skillTiers);
// Start with base values
const effects: ComputedEffects = {
maxManaMultiplier: 1,
maxManaBonus: 0,
regenMultiplier: 1,
regenBonus: 0,
clickManaMultiplier: 1,
clickManaBonus: 0,
meditationEfficiency: 1,
spellCostMultiplier: 1,
conversionEfficiency: 1,
baseDamageMultiplier: 1,
baseDamageBonus: 0,
attackSpeedMultiplier: 1,
critChanceBonus: 0,
critDamageMultiplier: 1.5,
elementalDamageMultiplier: 1,
studySpeedMultiplier: 1,
studyCostMultiplier: 1,
progressRetention: 0,
instantStudyChance: 0,
freeStudyChance: 0,
elementCapMultiplier: 1,
elementCapBonus: 0,
conversionCostMultiplier: 1,
doubleCraftChance: 0,
permanentRegenBonus: 0,
specials: new Set<string>(),
activeUpgrades,
};
// Apply each upgrade effect
for (const upgrade of activeUpgrades) {
const { effect } = upgrade;
if (effect.type === 'multiplier' && effect.stat && effect.value !== undefined) {
// Multiplier effects (multiply the stat)
switch (effect.stat) {
case 'maxMana':
effects.maxManaMultiplier *= effect.value;
break;
case 'regen':
effects.regenMultiplier *= effect.value;
break;
case 'clickMana':
effects.clickManaMultiplier *= effect.value;
break;
case 'meditationEfficiency':
effects.meditationEfficiency *= effect.value;
break;
case 'spellCost':
effects.spellCostMultiplier *= effect.value;
break;
case 'conversionEfficiency':
effects.conversionEfficiency *= effect.value;
break;
case 'baseDamage':
effects.baseDamageMultiplier *= effect.value;
break;
case 'attackSpeed':
effects.attackSpeedMultiplier *= effect.value;
break;
case 'elementalDamage':
effects.elementalDamageMultiplier *= effect.value;
break;
case 'studySpeed':
effects.studySpeedMultiplier *= effect.value;
break;
case 'elementCap':
effects.elementCapMultiplier *= effect.value;
break;
case 'conversionCost':
effects.conversionCostMultiplier *= effect.value;
break;
case 'costReduction':
// For cost reduction, higher is better (less cost)
// This is a multiplier on the reduction effectiveness
effects.studyCostMultiplier /= effect.value;
break;
}
} else if (effect.type === 'bonus' && effect.stat && effect.value !== undefined) {
// Bonus effects (add to the stat)
switch (effect.stat) {
case 'maxMana':
effects.maxManaBonus += effect.value;
break;
case 'regen':
effects.regenBonus += effect.value;
break;
case 'clickMana':
effects.clickManaBonus += effect.value;
break;
case 'baseDamage':
effects.baseDamageBonus += effect.value;
break;
case 'elementCap':
effects.elementCapBonus += effect.value;
break;
case 'permanentRegen':
effects.permanentRegenBonus += effect.value;
break;
}
} else if (effect.type === 'special' && effect.specialId) {
// Special effects - add to the set for game logic to check
effects.specials.add(effect.specialId);
}
}
return effects;
}
/**
* Check if a special effect is active
*/
export function hasSpecial(effects: ComputedEffects, specialId: string): boolean {
return effects?.specials?.has(specialId) ?? false;
}
/**
* Compute regen with special effects that depend on dynamic values
*/
export function computeDynamicRegen(
effects: ComputedEffects,
baseRegen: number,
maxMana: number,
currentMana: number,
incursionStrength: number
): number {
let regen = baseRegen;
// Mana Cascade: +0.1 regen per 100 max mana
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_CASCADE)) {
regen += Math.floor(maxMana / 100) * 0.1;
}
// Mana Torrent: +50% regen when above 75% mana
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_TORRENT) && currentMana > maxMana * 0.75) {
regen *= 1.5;
}
// Desperate Wells: +50% regen when below 25% mana
if (hasSpecial(effects, SPECIAL_EFFECTS.DESPERATE_WELLS) && currentMana < maxMana * 0.25) {
regen *= 1.5;
}
// Steady Stream: Regen immune to incursion
if (hasSpecial(effects, SPECIAL_EFFECTS.STEADY_STREAM)) {
return regen * effects.regenMultiplier;
}
// Apply incursion penalty
regen *= (1 - incursionStrength);
return regen * effects.regenMultiplier;
}
/**
* Compute click mana with special effects
*/
export function computeDynamicClickMana(
effects: ComputedEffects,
baseClickMana: number
): number {
let clickMana = baseClickMana;
// Mana Echo: 10% chance to gain double mana from clicks
// Note: The chance is handled in the click handler, this just returns the base
// The click handler should check hasSpecial and apply the 10% chance
return Math.floor((clickMana + effects.clickManaBonus) * effects.clickManaMultiplier);
}
/**
* Compute damage with special effects
*/
export function computeDynamicDamage(
effects: ComputedEffects,
baseDamage: number,
floorHPPct: number,
currentMana: number,
maxMana: number
): number {
let damage = baseDamage * effects.baseDamageMultiplier;
// Overpower: +50% damage when mana above 80%
if (hasSpecial(effects, SPECIAL_EFFECTS.OVERPOWER) && currentMana >= maxMana * 0.8) {
damage *= 1.5;
}
// Berserker: +50% damage when below 50% mana
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && currentMana < maxMana * 0.5) {
damage *= 1.5;
}
// Combo Master: Every 5th attack deals 3x damage
// Note: The hit counter is tracked in game state, this just returns the multiplier
// The combat handler should check hasSpecial and the hit count
return damage + effects.baseDamageBonus;
}

372
src/lib/game/utils.ts Executable file
View File

@@ -0,0 +1,372 @@
// ─── Game Utilities ───────────────────────────────────────────────────────────
import type { GameState, SpellCost } from './types';
import type { ComputedEffects } from './upgrade-effects';
import {
GUARDIANS,
SPELLS_DEF,
FLOOR_ELEM_CYCLE,
HOURS_PER_TICK,
MAX_DAY,
INCURSION_START_DAY,
ELEMENT_OPPOSITES,
} from './constants';
// ─── Formatting Functions ─────────────────────────────────────────────────────
export function fmt(n: number): string {
if (!isFinite(n) || isNaN(n)) return '0';
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
return Math.floor(n).toString();
}
export function fmtDec(n: number, d: number = 1): string {
return isFinite(n) ? n.toFixed(d) : '0';
}
// ─── Floor Helpers ────────────────────────────────────────────────────────────
export function getFloorMaxHP(floor: number): number {
if (GUARDIANS[floor]) return GUARDIANS[floor].hp;
// Improved scaling: slower early game, faster late game
const baseHP = 100;
const floorScaling = floor * 50;
const exponentialScaling = Math.pow(floor, 1.7);
return Math.floor(baseHP + floorScaling + exponentialScaling);
}
export function getFloorElement(floor: number): string {
return FLOOR_ELEM_CYCLE[(floor - 1) % 8];
}
// ─── Computed Stats Functions ─────────────────────────────────────────────────
export function computeMaxMana(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
effects?: ComputedEffects
): number {
const pu = state.prestigeUpgrades;
const base =
100 +
(state.skills.manaWell || 0) * 100 +
(pu.manaWell || 0) * 500;
// Apply upgrade effects if provided
if (effects) {
return Math.floor((base + effects.maxManaBonus) * effects.maxManaMultiplier);
}
return base;
}
export function computeElementMax(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
effects?: ComputedEffects
): number {
const pu = state.prestigeUpgrades;
const base = 10 + (state.skills.elemAttune || 0) * 50 + (pu.elementalAttune || 0) * 25;
// Apply upgrade effects if provided
if (effects) {
return Math.floor((base + effects.elementCapBonus) * effects.elementCapMultiplier);
}
return base;
}
export function computeRegen(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
effects?: ComputedEffects
): number {
const pu = state.prestigeUpgrades;
const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1;
const base =
2 +
(state.skills.manaFlow || 0) * 1 +
(state.skills.manaSpring || 0) * 2 +
(pu.manaFlow || 0) * 0.5;
let regen = base * temporalBonus;
// Apply upgrade effects if provided
if (effects) {
regen = (regen + effects.regenBonus + effects.permanentRegenBonus) * effects.regenMultiplier;
}
return regen;
}
/**
* Compute regen with dynamic special effects (needs current mana, max mana, incursion)
*/
export function computeEffectiveRegen(
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'rawMana' | 'incursionStrength' | 'skillUpgrades' | 'skillTiers'>,
effects?: ComputedEffects
): number {
// Base regen from existing function
let regen = computeRegen(state, effects);
const incursionStrength = state.incursionStrength || 0;
// Apply incursion penalty
regen *= (1 - incursionStrength);
return regen;
}
export function computeClickMana(state: Pick<GameState, 'skills'>): number {
return (
1 +
(state.skills.manaTap || 0) * 1 +
(state.skills.manaSurge || 0) * 3
);
}
// ─── Elemental Damage Bonus ──────────────────────────────────────────────────
// Elemental damage bonus: +50% if spell element opposes floor element (super effective)
// -25% if spell element matches its own opposite (weak)
export function getElementalBonus(spellElem: string, floorElem: string): number {
if (spellElem === 'raw') return 1.0; // Raw mana has no elemental bonus
if (spellElem === floorElem) return 1.25; // Same element: +25% damage
// Check for super effective first: spell is the opposite of floor
// e.g., casting water (opposite of fire) at fire floor = super effective
if (ELEMENT_OPPOSITES[floorElem] === spellElem) return 1.5; // Super effective: +50% damage
// Check for weak: spell's opposite matches floor
// e.g., casting fire (whose opposite is water) at water floor = weak
if (ELEMENT_OPPOSITES[spellElem] === floorElem) return 0.75; // Weak: -25% damage
return 1.0; // Neutral
}
// ─── Boon Bonuses ─────────────────────────────────────────────────────────────
// Helper to calculate total boon bonuses from signed pacts
export function getBoonBonuses(signedPacts: number[]): {
maxMana: number;
manaRegen: number;
castingSpeed: number;
elementalDamage: number;
rawDamage: number;
critChance: number;
critDamage: number;
spellEfficiency: number;
manaGain: number;
insightGain: number;
studySpeed: number;
prestigeInsight: number;
} {
const bonuses = {
maxMana: 0,
manaRegen: 0,
castingSpeed: 0,
elementalDamage: 0,
rawDamage: 0,
critChance: 0,
critDamage: 0,
spellEfficiency: 0,
manaGain: 0,
insightGain: 0,
studySpeed: 0,
prestigeInsight: 0,
};
for (const floor of signedPacts) {
const guardian = GUARDIANS[floor];
if (!guardian) continue;
for (const boon of guardian.boons) {
switch (boon.type) {
case 'maxMana':
bonuses.maxMana += boon.value;
break;
case 'manaRegen':
bonuses.manaRegen += boon.value;
break;
case 'castingSpeed':
bonuses.castingSpeed += boon.value;
break;
case 'elementalDamage':
bonuses.elementalDamage += boon.value;
break;
case 'rawDamage':
bonuses.rawDamage += boon.value;
break;
case 'critChance':
bonuses.critChance += boon.value;
break;
case 'critDamage':
bonuses.critDamage += boon.value;
break;
case 'spellEfficiency':
bonuses.spellEfficiency += boon.value;
break;
case 'manaGain':
bonuses.manaGain += boon.value;
break;
case 'insightGain':
bonuses.insightGain += boon.value;
break;
case 'studySpeed':
bonuses.studySpeed += boon.value;
break;
case 'prestigeInsight':
bonuses.prestigeInsight += boon.value;
break;
}
}
}
return bonuses;
}
// ─── Damage Calculation ───────────────────────────────────────────────────────
export function calcDamage(
state: Pick<GameState, 'skills' | 'signedPacts'>,
spellId: string,
floorElem?: string
): number {
const sp = SPELLS_DEF[spellId];
if (!sp) return 5;
const skills = state.skills;
const baseDmg = sp.dmg + (skills.combatTrain || 0) * 5;
const pct = 1 + (skills.arcaneFury || 0) * 0.1;
// Elemental mastery bonus
const elemMasteryBonus = 1 + (skills.elementalMastery || 0) * 0.15;
// Guardian bane bonus - check if current floor has a guardian with matching element
const isGuardianFloor = floorElem && Object.values(GUARDIANS).some(g => g.element === floorElem);
const guardianBonus = isGuardianFloor
? 1 + (skills.guardianBane || 0) * 0.2
: 1;
// Get boon bonuses from pacts
const boons = getBoonBonuses(state.signedPacts);
// Apply raw damage and elemental damage bonuses
const rawDamageMult = 1 + boons.rawDamage / 100;
const elemDamageMult = 1 + boons.elementalDamage / 100;
// Apply crit chance and damage from boons
const critChance = (skills.precision || 0) * 0.05 + boons.critChance / 100;
const critDamageMult = 1.5 + boons.critDamage / 100;
let damage = baseDmg * pct * elemMasteryBonus * guardianBonus * rawDamageMult * elemDamageMult;
// Apply elemental bonus if floor element provided
if (floorElem) {
damage *= getElementalBonus(sp.elem, floorElem);
}
// Apply crit
if (Math.random() < critChance) {
damage *= critDamageMult;
}
return damage;
}
// ─── Insight Calculation ──────────────────────────────────────────────────────
export function calcInsight(state: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'prestigeUpgrades' | 'skills'>): number {
const pu = state.prestigeUpgrades;
const skillBonus = 1 + (state.skills.insightHarvest || 0) * 0.1;
// Get boon bonuses for insight gain
const boons = getBoonBonuses(state.signedPacts);
const boonInsightMult = 1 + boons.insightGain / 100;
const mult = (1 + (pu.insightAmp || 0) * 0.25) * skillBonus * boonInsightMult;
// Add prestigeInsight bonus per loop
const prestigeInsightBonus = boons.prestigeInsight;
return Math.floor(
(state.maxFloorReached * 15 + state.totalManaGathered / 500 + state.signedPacts.length * 150 + prestigeInsightBonus) * mult
);
}
// ─── Meditation Bonus ─────────────────────────────────────────────────────────
// Meditation bonus now affects regen rate directly
export function getMeditationBonus(meditateTicks: number, skills: Record<string, number>, meditationEfficiency: number = 1): number {
const hasMeditation = skills.meditation === 1;
const hasDeepTrance = skills.deepTrance === 1;
const hasVoidMeditation = skills.voidMeditation === 1;
const hours = meditateTicks * HOURS_PER_TICK;
// Base meditation: ramps up over 4 hours to 1.5x
let bonus = 1 + Math.min(hours / 4, 0.5);
// With Meditation Focus: up to 2.5x after 4 hours
if (hasMeditation && hours >= 4) {
bonus = 2.5;
}
// With Deep Trance: up to 3.0x after 6 hours
if (hasDeepTrance && hours >= 6) {
bonus = 3.0;
}
// With Void Meditation: up to 5.0x after 8 hours
if (hasVoidMeditation && hours >= 8) {
bonus = 5.0;
}
// Apply meditation efficiency from upgrades (Deep Wellspring, etc.)
bonus *= meditationEfficiency;
return bonus;
}
// ─── Incursion Strength ───────────────────────────────────────────────────────
export function getIncursionStrength(day: number, hour: number): number {
if (day < INCURSION_START_DAY) return 0;
const totalHours = (day - INCURSION_START_DAY) * 24 + hour;
const maxHours = (MAX_DAY - INCURSION_START_DAY) * 24;
return Math.min(0.95, (totalHours / maxHours) * 0.95);
}
// ─── Spell Cost Helpers ───────────────────────────────────────────────────────
// Check if player can afford spell cost
export function canAffordSpellCost(
cost: SpellCost,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>
): boolean {
if (cost.type === 'raw') {
return rawMana >= cost.amount;
} else {
const elem = elements[cost.element || ''];
return elem && elem.unlocked && elem.current >= cost.amount;
}
}
// Deduct spell cost from appropriate mana pool
export function deductSpellCost(
cost: SpellCost,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
const newElements = { ...elements };
if (cost.type === 'raw') {
return { rawMana: rawMana - cost.amount, elements: newElements };
} else if (cost.element && newElements[cost.element]) {
newElements[cost.element] = {
...newElements[cost.element],
current: newElements[cost.element].current - cost.amount
};
return { rawMana, elements: newElements };
}
return { rawMana, elements: newElements };
}

6
src/lib/utils.ts Executable file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}