feat: recreate Crafting Tab with Fabricator and Enchanter sub-tabs
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 2m18s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 2m18s
- Add fabricator-recipes.ts with 12 recipes across earth/metal/crystal/sand mana types - Add FabricatorSubTab with mana-type filtering, recipe cards, materials inventory - Add EnchanterSubTab integrating existing 3-phase flow (Design → Prepare → Apply) - Add CraftingTab main component with clsx-based sub-tab system (matches DisciplinesTab pattern) - Wire into tabs barrel export and page.tsx with lazy loading + DebugName wrapper - Add 17 tests covering exports, displayNames, recipe data integrity, helpers, file sizes - All files under 400 lines
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
# Circular Dependencies
|
# Circular Dependencies
|
||||||
Generated: 2026-05-19T20:37:58.097Z
|
Generated: 2026-05-19T20:59:58.496Z
|
||||||
Found: 3 circular chain(s) — these MUST be fixed before modifying involved files.
|
Found: 3 circular chain(s) — these MUST be fixed before modifying involved files.
|
||||||
|
|
||||||
1. Processed 121 files (1.2s) (4 warnings)
|
1. Processed 121 files (1.2s) (4 warnings)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"generated": "2026-05-19T20:37:56.730Z",
|
"generated": "2026-05-19T20:59:57.136Z",
|
||||||
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
|
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
|
||||||
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ Mana-Loop/
|
|||||||
│ │ │ │ └── index.tsx
|
│ │ │ │ └── index.tsx
|
||||||
│ │ │ ├── shared/
|
│ │ │ ├── shared/
|
||||||
│ │ │ ├── tabs/
|
│ │ │ ├── tabs/
|
||||||
|
│ │ │ │ ├── CraftingTab/
|
||||||
|
│ │ │ │ │ ├── EnchanterSubTab.tsx
|
||||||
|
│ │ │ │ │ └── FabricatorSubTab.tsx
|
||||||
│ │ │ │ ├── DebugTab/
|
│ │ │ │ ├── DebugTab/
|
||||||
│ │ │ │ │ ├── AchievementDebugSection.tsx
|
│ │ │ │ │ ├── AchievementDebugSection.tsx
|
||||||
│ │ │ │ │ ├── AttunementDebugSection.tsx
|
│ │ │ │ │ ├── AttunementDebugSection.tsx
|
||||||
@@ -112,6 +115,8 @@ Mana-Loop/
|
|||||||
│ │ │ │ ├── ActivityLog.tsx
|
│ │ │ │ ├── ActivityLog.tsx
|
||||||
│ │ │ │ ├── AttunementsTab.test.ts
|
│ │ │ │ ├── AttunementsTab.test.ts
|
||||||
│ │ │ │ ├── AttunementsTab.tsx
|
│ │ │ │ ├── AttunementsTab.tsx
|
||||||
|
│ │ │ │ ├── CraftingTab.test.ts
|
||||||
|
│ │ │ │ ├── CraftingTab.tsx
|
||||||
│ │ │ │ ├── DebugTab.test.ts
|
│ │ │ │ ├── DebugTab.test.ts
|
||||||
│ │ │ │ ├── DebugTab.tsx
|
│ │ │ │ ├── DebugTab.tsx
|
||||||
│ │ │ │ ├── DisciplinesTab.tsx
|
│ │ │ │ ├── DisciplinesTab.tsx
|
||||||
@@ -257,6 +262,7 @@ Mana-Loop/
|
|||||||
│ │ │ ├── crafting-recipes.ts
|
│ │ │ ├── crafting-recipes.ts
|
||||||
│ │ │ ├── enchantment-effects.ts
|
│ │ │ ├── enchantment-effects.ts
|
||||||
│ │ │ ├── enchantment-types.ts
|
│ │ │ ├── enchantment-types.ts
|
||||||
|
│ │ │ ├── fabricator-recipes.ts
|
||||||
│ │ │ └── loot-drops.ts
|
│ │ │ └── loot-drops.ts
|
||||||
│ │ ├── effects/
|
│ │ ├── effects/
|
||||||
│ │ │ ├── discipline-effects.ts
|
│ │ │ ├── discipline-effects.ts
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const EquipmentTab = lazy(() => import('@/components/game/tabs').then(module =>
|
|||||||
const GolemancyTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.GolemancyTab })));
|
const GolemancyTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.GolemancyTab })));
|
||||||
const GuardianPactsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.GuardianPactsTab })));
|
const GuardianPactsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.GuardianPactsTab })));
|
||||||
const SpireSummaryTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.SpireSummaryTab })));
|
const SpireSummaryTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.SpireSummaryTab })));
|
||||||
|
const CraftingTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.CraftingTab })));
|
||||||
|
|
||||||
const TabLoadingFallback = () => <div className="p-4 text-center text-gray-400">Loading...</div>;
|
const TabLoadingFallback = () => <div className="p-4 text-center text-gray-400">Loading...</div>;
|
||||||
|
|
||||||
@@ -249,6 +250,7 @@ export default function ManaLoopGame() {
|
|||||||
<TabsTrigger value="golemancy" className="text-xs px-2 py-1">🗿 Golemancy</TabsTrigger>
|
<TabsTrigger value="golemancy" className="text-xs px-2 py-1">🗿 Golemancy</TabsTrigger>
|
||||||
<TabsTrigger value="pacts" className="text-xs px-2 py-1">📜 Pacts</TabsTrigger>
|
<TabsTrigger value="pacts" className="text-xs px-2 py-1">📜 Pacts</TabsTrigger>
|
||||||
<TabsTrigger value="spire" className="text-xs px-2 py-1">🏔️ Spire</TabsTrigger>
|
<TabsTrigger value="spire" className="text-xs px-2 py-1">🏔️ Spire</TabsTrigger>
|
||||||
|
<TabsTrigger value="crafting" className="text-xs px-2 py-1">⚒️ Crafting</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="spells">
|
<TabsContent value="spells">
|
||||||
@@ -342,6 +344,14 @@ export default function ManaLoopGame() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="crafting">
|
||||||
|
<ErrorBoundary fallback={<div className="p-4 text-red-400">crafting tab failed to load.</div>}>
|
||||||
|
<Suspense fallback={<TabLoadingFallback />}>
|
||||||
|
<CraftingTab />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
// ─── Test: CraftingTab barrel export ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CraftingTab module structure', () => {
|
||||||
|
it('exports CraftingTab from its module', async () => {
|
||||||
|
const mod = await import('./CraftingTab');
|
||||||
|
expect(mod.CraftingTab).toBeDefined();
|
||||||
|
expect(typeof mod.CraftingTab).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CraftingTab has correct displayName', async () => {
|
||||||
|
const { CraftingTab } = await import('./CraftingTab');
|
||||||
|
expect(CraftingTab.displayName).toBe('CraftingTab');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: CraftingTab in tabs barrel index ────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Tab barrel export', () => {
|
||||||
|
it('includes CraftingTab in the tabs index', async () => {
|
||||||
|
const mod = await import('@/components/game/tabs');
|
||||||
|
expect(mod.CraftingTab).toBeDefined();
|
||||||
|
expect(typeof mod.CraftingTab).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: FabricatorSubTab ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('FabricatorSubTab module', () => {
|
||||||
|
it('exports FabricatorSubTab', async () => {
|
||||||
|
const mod = await import('./CraftingTab/FabricatorSubTab');
|
||||||
|
expect(mod.FabricatorSubTab).toBeDefined();
|
||||||
|
expect(typeof mod.FabricatorSubTab).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FabricatorSubTab has correct displayName', async () => {
|
||||||
|
const { FabricatorSubTab } = await import('./CraftingTab/FabricatorSubTab');
|
||||||
|
expect(FabricatorSubTab.displayName).toBe('FabricatorSubTab');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: EnchanterSubTab ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('EnchanterSubTab module', () => {
|
||||||
|
it('exports EnchanterSubTab', async () => {
|
||||||
|
const mod = await import('./CraftingTab/EnchanterSubTab');
|
||||||
|
expect(mod.EnchanterSubTab).toBeDefined();
|
||||||
|
expect(typeof mod.EnchanterSubTab).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('EnchanterSubTab has correct displayName', async () => {
|
||||||
|
const { EnchanterSubTab } = await import('./CraftingTab/EnchanterSubTab');
|
||||||
|
expect(EnchanterSubTab.displayName).toBe('EnchanterSubTab');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Fabricator recipes data ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Fabricator recipes data', () => {
|
||||||
|
it('exports FABRICATOR_RECIPES', async () => {
|
||||||
|
const mod = await import('@/lib/game/data/fabricator-recipes');
|
||||||
|
expect(mod.FABRICATOR_RECIPES).toBeDefined();
|
||||||
|
expect(Object.keys(mod.FABRICATOR_RECIPES).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all recipes have required fields', async () => {
|
||||||
|
const { FABRICATOR_RECIPES } = await import('@/lib/game/data/fabricator-recipes');
|
||||||
|
for (const recipe of Object.values(FABRICATOR_RECIPES)) {
|
||||||
|
expect(recipe.id).toBeTruthy();
|
||||||
|
expect(recipe.name).toBeTruthy();
|
||||||
|
expect(recipe.description).toBeTruthy();
|
||||||
|
expect(recipe.manaType).toBeTruthy();
|
||||||
|
expect(recipe.equipmentTypeId).toBeTruthy();
|
||||||
|
expect(recipe.slot).toBeTruthy();
|
||||||
|
expect(recipe.materials).toBeDefined();
|
||||||
|
expect(recipe.manaCost).toBeGreaterThan(0);
|
||||||
|
expect(recipe.craftTime).toBeGreaterThan(0);
|
||||||
|
expect(recipe.rarity).toBeTruthy();
|
||||||
|
expect(recipe.gearTrait).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only uses valid solid/structural mana types', async () => {
|
||||||
|
const { FABRICATOR_RECIPES } = await import('@/lib/game/data/fabricator-recipes');
|
||||||
|
const validManaTypes = new Set(['earth', 'metal', 'crystal', 'sand']);
|
||||||
|
for (const recipe of Object.values(FABRICATOR_RECIPES)) {
|
||||||
|
expect(validManaTypes.has(recipe.manaType)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no fire, water, air, light, dark, death, stellar, or void gear recipes', async () => {
|
||||||
|
const { FABRICATOR_RECIPES } = await import('@/lib/game/data/fabricator-recipes');
|
||||||
|
const invalidTypes = new Set(['fire', 'water', 'air', 'light', 'dark', 'death', 'stellar', 'void']);
|
||||||
|
for (const recipe of Object.values(FABRICATOR_RECIPES)) {
|
||||||
|
expect(invalidTypes.has(recipe.manaType)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRecipesByManaType filters correctly', async () => {
|
||||||
|
const { getRecipesByManaType, FABRICATOR_RECIPES } = await import('@/lib/game/data/fabricator-recipes');
|
||||||
|
const earthRecipes = getRecipesByManaType('earth');
|
||||||
|
expect(earthRecipes.length).toBeGreaterThan(0);
|
||||||
|
for (const r of earthRecipes) {
|
||||||
|
expect(r.manaType).toBe('earth');
|
||||||
|
}
|
||||||
|
|
||||||
|
const empty = getRecipesByManaType('nonexistent');
|
||||||
|
expect(empty).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRecipeById returns correct recipe', async () => {
|
||||||
|
const { getRecipeById, FABRICATOR_RECIPES } = await import('@/lib/game/data/fabricator-recipes');
|
||||||
|
const firstId = Object.values(FABRICATOR_RECIPES)[0].id;
|
||||||
|
const recipe = getRecipeById(firstId);
|
||||||
|
expect(recipe).toBeDefined();
|
||||||
|
expect(recipe!.id).toBe(firstId);
|
||||||
|
|
||||||
|
const missing = getRecipeById('nonexistent');
|
||||||
|
expect(missing).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canCraftRecipe returns correct availability', async () => {
|
||||||
|
const { canCraftRecipe, FABRICATOR_RECIPES } = await import('@/lib/game/data/fabricator-recipes');
|
||||||
|
const recipe = Object.values(FABRICATOR_RECIPES)[0];
|
||||||
|
|
||||||
|
// Empty materials => cannot craft
|
||||||
|
const result1 = canCraftRecipe(recipe, {}, 0);
|
||||||
|
expect(result1.canCraft).toBe(false);
|
||||||
|
expect(Object.keys(result1.missingMaterials).length).toBeGreaterThan(0);
|
||||||
|
expect(result1.missingMana).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Sufficient materials and mana => can craft
|
||||||
|
const sufficientMats: Record<string, number> = {};
|
||||||
|
for (const [matId, amount] of Object.entries(recipe.materials)) {
|
||||||
|
sufficientMats[matId] = amount;
|
||||||
|
}
|
||||||
|
const result2 = canCraftRecipe(recipe, sufficientMats, recipe.manaCost);
|
||||||
|
expect(result2.canCraft).toBe(true);
|
||||||
|
expect(Object.keys(result2.missingMaterials)).toHaveLength(0);
|
||||||
|
expect(result2.missingMana).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: File size limits ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('File size limits (400 lines max)', () => {
|
||||||
|
it('CraftingTab.tsx is under 400 lines', async () => {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const path = await import('path');
|
||||||
|
const content = fs.readFileSync(
|
||||||
|
path.join(__dirname, 'CraftingTab.tsx'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
const lines = content.split('\n').length;
|
||||||
|
expect(lines).toBeLessThan(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FabricatorSubTab.tsx is under 400 lines', async () => {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const path = await import('path');
|
||||||
|
const content = fs.readFileSync(
|
||||||
|
path.join(__dirname, 'CraftingTab', 'FabricatorSubTab.tsx'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
const lines = content.split('\n').length;
|
||||||
|
expect(lines).toBeLessThan(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('EnchanterSubTab.tsx is under 400 lines', async () => {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const path = await import('path');
|
||||||
|
const content = fs.readFileSync(
|
||||||
|
path.join(__dirname, 'CraftingTab', 'EnchanterSubTab.tsx'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
const lines = content.split('\n').length;
|
||||||
|
expect(lines).toBeLessThan(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Hammer, Sparkles } from 'lucide-react';
|
||||||
|
import { DebugName } from '@/components/game/debug/debug-context';
|
||||||
|
import { FabricatorSubTab } from './CraftingTab/FabricatorSubTab';
|
||||||
|
import { EnchanterSubTab } from './CraftingTab/EnchanterSubTab';
|
||||||
|
|
||||||
|
type CraftingAttunement = 'fabricator' | 'enchanter';
|
||||||
|
|
||||||
|
interface CraftingSubTab {
|
||||||
|
key: CraftingAttunement;
|
||||||
|
label: string;
|
||||||
|
icon: typeof Hammer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRAFTING_SUB_TABS: CraftingSubTab[] = [
|
||||||
|
{ key: 'fabricator', label: 'Fabricator', icon: Hammer },
|
||||||
|
{ key: 'enchanter', label: 'Enchanter', icon: Sparkles },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CraftingTab() {
|
||||||
|
const [activeSubTab, setActiveSubTab] = useState<CraftingAttunement>('fabricator');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DebugName name="CraftingTab">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Sub-tab bar — same clsx pattern as DisciplinesTab */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{CRAFTING_SUB_TABS.map(({ key, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setActiveSubTab(key)}
|
||||||
|
className={clsx('rounded px-3 py-1 text-sm font-medium flex items-center gap-1.5', {
|
||||||
|
'bg-amber-600 text-white': activeSubTab === key,
|
||||||
|
'text-gray-400 hover:text-gray-200': activeSubTab !== key,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-tab content */}
|
||||||
|
{activeSubTab === 'fabricator' && <FabricatorSubTab />}
|
||||||
|
{activeSubTab === 'enchanter' && <EnchanterSubTab />}
|
||||||
|
</div>
|
||||||
|
</DebugName>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CraftingTab.displayName = 'CraftingTab';
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { PenLine, FlaskConical, Sparkles } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
EnchantmentDesigner,
|
||||||
|
EnchantmentPreparer,
|
||||||
|
EnchantmentApplier,
|
||||||
|
} from '@/components/game/crafting';
|
||||||
|
import { useCraftingStore } from '@/lib/game/stores';
|
||||||
|
import type { DesignEffect } from '@/lib/game/types';
|
||||||
|
|
||||||
|
type EnchanterPhase = 'design' | 'prepare' | 'apply';
|
||||||
|
|
||||||
|
const PHASES: { key: EnchanterPhase; label: string; icon: typeof PenLine }[] = [
|
||||||
|
{ key: 'design', label: 'Design', icon: PenLine },
|
||||||
|
{ key: 'prepare', label: 'Prepare', icon: FlaskConical },
|
||||||
|
{ key: 'apply', label: 'Apply', icon: Sparkles },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function EnchanterSubTab() {
|
||||||
|
const [activePhase, setActivePhase] = useState<EnchanterPhase>('design');
|
||||||
|
|
||||||
|
// Shared state for the enchantment flow
|
||||||
|
const [selectedEquipmentType, setSelectedEquipmentType] = useState<string | null>(null);
|
||||||
|
const [selectedEffects, setSelectedEffects] = useState<DesignEffect[]>([]);
|
||||||
|
const [designName, setDesignName] = useState('');
|
||||||
|
const [selectedDesign, setSelectedDesign] = useState<string | null>(null);
|
||||||
|
const [selectedEquipmentInstance, setSelectedEquipmentInstance] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const resetEnchantmentSelection = useCraftingStore((s) => s.resetEnchantmentSelection);
|
||||||
|
|
||||||
|
const handlePhaseChange = (phase: EnchanterPhase) => {
|
||||||
|
setActivePhase(phase);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnchantmentApplied = () => {
|
||||||
|
// Reset selection after successful application
|
||||||
|
resetEnchantmentSelection();
|
||||||
|
setSelectedEquipmentInstance(null);
|
||||||
|
setSelectedDesign(null);
|
||||||
|
// Go back to design phase
|
||||||
|
setActivePhase('design');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Phase selector */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{PHASES.map(({ key, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => handlePhaseChange(key)}
|
||||||
|
className={clsx('rounded px-3 py-1 text-sm font-medium flex items-center gap-1.5', {
|
||||||
|
'bg-purple-600 text-white': activePhase === key,
|
||||||
|
'text-gray-400 hover:text-gray-200': activePhase !== key,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase content */}
|
||||||
|
{activePhase === 'design' && (
|
||||||
|
<EnchantmentDesigner
|
||||||
|
selectedEquipmentType={selectedEquipmentType}
|
||||||
|
setSelectedEquipmentType={setSelectedEquipmentType}
|
||||||
|
selectedEffects={selectedEffects}
|
||||||
|
setSelectedEffects={setSelectedEffects}
|
||||||
|
designName={designName}
|
||||||
|
setDesignName={setDesignName}
|
||||||
|
selectedDesign={selectedDesign}
|
||||||
|
setSelectedDesign={setSelectedDesign}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activePhase === 'prepare' && (
|
||||||
|
<EnchantmentPreparer
|
||||||
|
selectedEquipmentInstance={selectedEquipmentInstance}
|
||||||
|
setSelectedEquipmentInstance={setSelectedEquipmentInstance}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activePhase === 'apply' && (
|
||||||
|
<EnchantmentApplier
|
||||||
|
selectedEquipmentInstance={selectedEquipmentInstance}
|
||||||
|
setSelectedEquipmentInstance={setSelectedEquipmentInstance}
|
||||||
|
selectedDesign={selectedDesign}
|
||||||
|
setSelectedDesign={setSelectedDesign}
|
||||||
|
onEnchantmentApplied={handleEnchantmentApplied}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EnchanterSubTab.displayName = 'EnchanterSubTab';
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } 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 { Anvil, Hammer, Package } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
FABRICATOR_RECIPES,
|
||||||
|
getRecipesByManaType,
|
||||||
|
canCraftRecipe,
|
||||||
|
} from '@/lib/game/data/fabricator-recipes';
|
||||||
|
import { LOOT_DROPS, LOOT_RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||||
|
import { useCraftingStore, useManaStore } from '@/lib/game/stores';
|
||||||
|
import type { FabricatorRecipe } from '@/lib/game/data/fabricator-recipes';
|
||||||
|
|
||||||
|
const MANA_TYPE_LABELS: Record<string, string> = {
|
||||||
|
earth: '⛰️ Earth',
|
||||||
|
metal: '🔩 Metal',
|
||||||
|
crystal: '💎 Crystal',
|
||||||
|
sand: '🏜️ Sand',
|
||||||
|
};
|
||||||
|
|
||||||
|
function RecipeCard({
|
||||||
|
recipe,
|
||||||
|
materials,
|
||||||
|
manaAmount,
|
||||||
|
onCraft,
|
||||||
|
isCrafting,
|
||||||
|
}: {
|
||||||
|
recipe: FabricatorRecipe;
|
||||||
|
materials: Record<string, number>;
|
||||||
|
manaAmount: number;
|
||||||
|
onCraft: (recipe: FabricatorRecipe) => void;
|
||||||
|
isCrafting: boolean;
|
||||||
|
}) {
|
||||||
|
const { canCraft, missingMaterials, missingMana } = canCraftRecipe(
|
||||||
|
recipe,
|
||||||
|
materials,
|
||||||
|
manaAmount,
|
||||||
|
);
|
||||||
|
const rarityStyle = LOOT_RARITY_COLORS[recipe.rarity];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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">
|
||||||
|
{MANA_TYPE_LABELS[recipe.manaType] ?? recipe.manaType}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-400 mb-2">{recipe.description}</div>
|
||||||
|
<div className="text-xs text-amber-400/80 italic mb-2">{recipe.gearTrait}</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 = 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_TYPE_LABELS[recipe.manaType]?.split(' ')[1] ?? recipe.manaType} Mana:</span>
|
||||||
|
<span className={manaAmount >= recipe.manaCost ? 'text-green-400' : 'text-red-400'}>
|
||||||
|
{manaAmount} / {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 || isCrafting}
|
||||||
|
onClick={() => onCraft(recipe)}
|
||||||
|
>
|
||||||
|
{canCraft ? 'Craft' : 'Missing Resources'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FabricatorSubTab() {
|
||||||
|
const [selectedManaType, setSelectedManaType] = useState<string>('earth');
|
||||||
|
|
||||||
|
const lootInventory = useCraftingStore((s) => s.lootInventory);
|
||||||
|
const equipmentCraftingProgress = useCraftingStore((s) => s.equipmentCraftingProgress);
|
||||||
|
const rawMana = useManaStore((s) => s.rawMana);
|
||||||
|
const startCraftingEquipment = useCraftingStore((s) => s.startCraftingEquipment);
|
||||||
|
const cancelEquipmentCrafting = useCraftingStore((s) => s.cancelEquipmentCrafting);
|
||||||
|
|
||||||
|
const availableManaTypes = useMemo(() => {
|
||||||
|
return [...new Set(FABRICATOR_RECIPES.map((r) => r.manaType))];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredRecipes = useMemo(
|
||||||
|
() => getRecipesByManaType(selectedManaType),
|
||||||
|
[selectedManaType],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCrafting = equipmentCraftingProgress !== null;
|
||||||
|
|
||||||
|
const handleCraft = (recipe: FabricatorRecipe) => {
|
||||||
|
// Use the existing equipment crafting system with a fabricator-specific blueprint ID
|
||||||
|
startCraftingEquipment(`fabricator-${recipe.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Mana type filter */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{availableManaTypes.map((mt) => (
|
||||||
|
<Button
|
||||||
|
key={mt}
|
||||||
|
variant={selectedManaType === mt ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedManaType(mt)}
|
||||||
|
>
|
||||||
|
{MANA_TYPE_LABELS[mt] ?? mt}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Recipe list */}
|
||||||
|
<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">
|
||||||
|
<Hammer className="w-4 h-4" />
|
||||||
|
{MANA_TYPE_LABELS[selectedManaType] ?? selectedManaType} Recipes
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isCrafting ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Crafting: {equipmentCraftingProgress.blueprintId}
|
||||||
|
</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: {equipmentCraftingProgress.manaSpent}</span>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={cancelEquipmentCrafting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-80">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredRecipes.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-400 py-4">
|
||||||
|
<Anvil className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No recipes for this mana type yet.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredRecipes.map((recipe) => (
|
||||||
|
<RecipeCard
|
||||||
|
key={recipe.id}
|
||||||
|
recipe={recipe}
|
||||||
|
materials={lootInventory.materials}
|
||||||
|
manaAmount={rawMana}
|
||||||
|
onCraft={handleCraft}
|
||||||
|
isCrafting={isCrafting}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</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-80">
|
||||||
|
{Object.keys(lootInventory.materials).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 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 = LOOT_RARITY_COLORS[drop.rarity];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={matId}
|
||||||
|
className="p-2 rounded border bg-gray-800/50"
|
||||||
|
style={{ borderColor: rarityStyle?.color }}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-semibold" style={{ color: rarityStyle?.color }}>
|
||||||
|
{drop.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">x{count}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FabricatorSubTab.displayName = 'FabricatorSubTab';
|
||||||
@@ -12,3 +12,4 @@ export { EquipmentTab } from './EquipmentTab';
|
|||||||
export { GolemancyTab } from './GolemancyTab';
|
export { GolemancyTab } from './GolemancyTab';
|
||||||
export { GuardianPactsTab } from './GuardianPactsTab';
|
export { GuardianPactsTab } from './GuardianPactsTab';
|
||||||
export { SpireSummaryTab } from './SpireSummaryTab';
|
export { SpireSummaryTab } from './SpireSummaryTab';
|
||||||
|
export { CraftingTab } from './CraftingTab';
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// ─── Fabricator Recipes ──────────────────────────────────────────────────────
|
||||||
|
// Crafting recipes for the Fabricator attunement.
|
||||||
|
// Each recipe is tied to a mana type the player has unlocked.
|
||||||
|
|
||||||
|
import type { EquipmentSlot } from './equipment/types';
|
||||||
|
|
||||||
|
export interface FabricatorRecipe {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
/** Mana type required to craft this recipe (must be unlocked) */
|
||||||
|
manaType: string;
|
||||||
|
/** Equipment type ID produced */
|
||||||
|
equipmentTypeId: string;
|
||||||
|
/** Which slot the resulting equipment occupies */
|
||||||
|
slot: EquipmentSlot;
|
||||||
|
/** Materials required: materialId -> count */
|
||||||
|
materials: Record<string, number>;
|
||||||
|
/** Mana cost in the recipe's mana type */
|
||||||
|
manaCost: number;
|
||||||
|
/** Craft time in hours */
|
||||||
|
craftTime: number;
|
||||||
|
/** Rarity tier */
|
||||||
|
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||||
|
/** Flavor text describing the gear's properties */
|
||||||
|
gearTrait: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FABRICATOR_RECIPES: FabricatorRecipe[] = [
|
||||||
|
// ─── Earth Gear (Compacted Earth — high defense) ──────────────────────
|
||||||
|
{
|
||||||
|
id: 'earthHelm',
|
||||||
|
name: 'Earthen Helm',
|
||||||
|
description: 'A sturdy helm carved from compacted stone.',
|
||||||
|
manaType: 'earth',
|
||||||
|
equipmentTypeId: 'wizardHat',
|
||||||
|
slot: 'head',
|
||||||
|
materials: { manaCrystalDust: 4, arcaneShard: 2 },
|
||||||
|
manaCost: 200,
|
||||||
|
craftTime: 3,
|
||||||
|
rarity: 'uncommon',
|
||||||
|
gearTrait: '+15% physical resistance',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'earthChest',
|
||||||
|
name: 'Stoneguard Armor',
|
||||||
|
description: 'Heavy stone plates layered over leather. Slow but nearly impenetrable.',
|
||||||
|
manaType: 'earth',
|
||||||
|
equipmentTypeId: 'scholarRobe',
|
||||||
|
slot: 'body',
|
||||||
|
materials: { manaCrystalDust: 8, arcaneShard: 4, elementalCore: 1 },
|
||||||
|
manaCost: 500,
|
||||||
|
craftTime: 6,
|
||||||
|
rarity: 'rare',
|
||||||
|
gearTrait: '+25% physical resistance, -10% cast speed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'earthBoots',
|
||||||
|
name: 'Stonegreaves',
|
||||||
|
description: 'Boots reinforced with compacted earth. Firm footing in any battle.',
|
||||||
|
manaType: 'earth',
|
||||||
|
equipmentTypeId: 'travelerBoots',
|
||||||
|
slot: 'feet',
|
||||||
|
materials: { manaCrystalDust: 3, arcaneShard: 1 },
|
||||||
|
manaCost: 150,
|
||||||
|
craftTime: 2,
|
||||||
|
rarity: 'uncommon',
|
||||||
|
gearTrait: '+10% physical resistance',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Metal Gear (Fire+Earth — balanced offense/defense) ──────────────
|
||||||
|
{
|
||||||
|
id: 'metalBlade',
|
||||||
|
name: 'Metal Blade',
|
||||||
|
description: 'A blade forged from condensed metal mana. Sharp and durable.',
|
||||||
|
manaType: 'metal',
|
||||||
|
equipmentTypeId: 'steelBlade',
|
||||||
|
slot: 'mainHand',
|
||||||
|
materials: { manaCrystalDust: 6, arcaneShard: 3, elementalCore: 2 },
|
||||||
|
manaCost: 400,
|
||||||
|
craftTime: 5,
|
||||||
|
rarity: 'rare',
|
||||||
|
gearTrait: '+20% damage, +10% durability',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'metalShield',
|
||||||
|
name: 'Metal Kite Shield',
|
||||||
|
description: 'A polished metal shield. Reliable protection without excessive weight.',
|
||||||
|
manaType: 'metal',
|
||||||
|
equipmentTypeId: 'runicShield',
|
||||||
|
slot: 'offHand',
|
||||||
|
materials: { manaCrystalDust: 7, arcaneShard: 4, elementalCore: 1 },
|
||||||
|
manaCost: 450,
|
||||||
|
craftTime: 5,
|
||||||
|
rarity: 'rare',
|
||||||
|
gearTrait: '+20% block chance',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'metalGloves',
|
||||||
|
name: 'Metalweave Gauntlets',
|
||||||
|
description: 'Gauntlets woven with metal mana threads. Protective yet dexterous.',
|
||||||
|
manaType: 'metal',
|
||||||
|
equipmentTypeId: 'spellweaveGloves',
|
||||||
|
slot: 'hands',
|
||||||
|
materials: { manaCrystalDust: 4, arcaneShard: 2 },
|
||||||
|
manaCost: 250,
|
||||||
|
craftTime: 3,
|
||||||
|
rarity: 'uncommon',
|
||||||
|
gearTrait: '+10% damage, +10% physical resistance',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Crystal Gear (Sand+Sand+Light — high enchantment capacity) ──────
|
||||||
|
{
|
||||||
|
id: 'crystalWand',
|
||||||
|
name: 'Crystal Focus Wand',
|
||||||
|
description: 'A wand with a pure crystal core. Exceptional mana conductivity.',
|
||||||
|
manaType: 'crystal',
|
||||||
|
equipmentTypeId: 'crystalWand',
|
||||||
|
slot: 'mainHand',
|
||||||
|
materials: { manaCrystalDust: 10, arcaneShard: 5, elementalCore: 3 },
|
||||||
|
manaCost: 600,
|
||||||
|
craftTime: 6,
|
||||||
|
rarity: 'epic',
|
||||||
|
gearTrait: '+40% enchantment capacity',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crystalRing',
|
||||||
|
name: 'Crystal Ring',
|
||||||
|
description: 'A ring set with a mana crystal. Amplifies enchantment effects.',
|
||||||
|
manaType: 'crystal',
|
||||||
|
equipmentTypeId: 'silverRing',
|
||||||
|
slot: 'accessory1',
|
||||||
|
materials: { manaCrystalDust: 5, arcaneShard: 3, elementalCore: 1 },
|
||||||
|
manaCost: 350,
|
||||||
|
craftTime: 3,
|
||||||
|
rarity: 'rare',
|
||||||
|
gearTrait: '+15% enchantment capacity',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crystalAmulet',
|
||||||
|
name: 'Crystal Pendant',
|
||||||
|
description: 'An amulet housing a crystal shard. Enhances all enchantments worn.',
|
||||||
|
manaType: 'crystal',
|
||||||
|
equipmentTypeId: 'silverAmulet',
|
||||||
|
slot: 'accessory2',
|
||||||
|
materials: { manaCrystalDust: 6, arcaneShard: 3, elementalCore: 2 },
|
||||||
|
manaCost: 400,
|
||||||
|
craftTime: 4,
|
||||||
|
rarity: 'rare',
|
||||||
|
gearTrait: '+10% all enchantment effects',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Sand Gear (Earth+Water — lightweight, agile) ────────────────────
|
||||||
|
{
|
||||||
|
id: 'sandBoots',
|
||||||
|
name: 'Sandstrider Boots',
|
||||||
|
description: 'Boots infused with sand mana. Light as air, silent as dust.',
|
||||||
|
manaType: 'sand',
|
||||||
|
equipmentTypeId: 'travelerBoots',
|
||||||
|
slot: 'feet',
|
||||||
|
materials: { manaCrystalDust: 3, arcaneShard: 1 },
|
||||||
|
manaCost: 120,
|
||||||
|
craftTime: 2,
|
||||||
|
rarity: 'uncommon',
|
||||||
|
gearTrait: '+15% cast speed, +10% evasion',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sandGloves',
|
||||||
|
name: 'Sandweave Gloves',
|
||||||
|
description: 'Gloves woven from sand mana. Nimble fingers for delicate enchanting.',
|
||||||
|
manaType: 'sand',
|
||||||
|
equipmentTypeId: 'spellweaveGloves',
|
||||||
|
slot: 'hands',
|
||||||
|
materials: { manaCrystalDust: 3, arcaneShard: 2 },
|
||||||
|
manaCost: 140,
|
||||||
|
craftTime: 2,
|
||||||
|
rarity: 'uncommon',
|
||||||
|
gearTrait: '+10% cast speed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sandVest',
|
||||||
|
name: 'Sandcloth Vest',
|
||||||
|
description: 'A light vest woven from sand mana. Offers minimal protection but maximum mobility.',
|
||||||
|
manaType: 'sand',
|
||||||
|
equipmentTypeId: 'scholarRobe',
|
||||||
|
slot: 'body',
|
||||||
|
materials: { manaCrystalDust: 5, arcaneShard: 2, elementalCore: 1 },
|
||||||
|
manaCost: 300,
|
||||||
|
craftTime: 4,
|
||||||
|
rarity: 'rare',
|
||||||
|
gearTrait: '+20% cast speed, +5% evasion',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getRecipesByManaType(manaType: string): FabricatorRecipe[] {
|
||||||
|
return FABRICATOR_RECIPES.filter(r => r.manaType === manaType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecipeById(id: string): FabricatorRecipe | undefined {
|
||||||
|
return FABRICATOR_RECIPES.find(r => r.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canCraftRecipe(
|
||||||
|
recipe: FabricatorRecipe,
|
||||||
|
materials: Record<string, number>,
|
||||||
|
manaAmount: 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 - manaAmount);
|
||||||
|
if (missingMana > 0) {
|
||||||
|
canCraft = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { canCraft, missingMaterials, missingMana };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user