From 1c7fc8c5514b2224084e5d7f5a8fecb01b17a09a Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Wed, 20 May 2026 02:32:37 +0200 Subject: [PATCH] feat: recreate Crafting Tab with Fabricator and Enchanter sub-tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 2 +- docs/project-structure.txt | 6 + src/app/page.tsx | 10 + src/components/game/tabs/CraftingTab.test.ts | 180 ++++++++++++ src/components/game/tabs/CraftingTab.tsx | 54 ++++ .../game/tabs/CraftingTab/EnchanterSubTab.tsx | 100 +++++++ .../tabs/CraftingTab/FabricatorSubTab.tsx | 260 ++++++++++++++++++ src/components/game/tabs/index.ts | 1 + src/lib/game/data/fabricator-recipes.ts | 227 +++++++++++++++ 10 files changed, 840 insertions(+), 2 deletions(-) create mode 100644 src/components/game/tabs/CraftingTab.test.ts create mode 100644 src/components/game/tabs/CraftingTab.tsx create mode 100644 src/components/game/tabs/CraftingTab/EnchanterSubTab.tsx create mode 100644 src/components/game/tabs/CraftingTab/FabricatorSubTab.tsx create mode 100644 src/lib/game/data/fabricator-recipes.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index ba2b365..d7dde38 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,5 +1,5 @@ # 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. 1. Processed 121 files (1.2s) (4 warnings) diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index dc26a7f..a7d1ba5 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_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.", "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." }, diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 08bfea2..878bdc7 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -88,6 +88,9 @@ Mana-Loop/ │ │ │ │ └── index.tsx │ │ │ ├── shared/ │ │ │ ├── tabs/ +│ │ │ │ ├── CraftingTab/ +│ │ │ │ │ ├── EnchanterSubTab.tsx +│ │ │ │ │ └── FabricatorSubTab.tsx │ │ │ │ ├── DebugTab/ │ │ │ │ │ ├── AchievementDebugSection.tsx │ │ │ │ │ ├── AttunementDebugSection.tsx @@ -112,6 +115,8 @@ Mana-Loop/ │ │ │ │ ├── ActivityLog.tsx │ │ │ │ ├── AttunementsTab.test.ts │ │ │ │ ├── AttunementsTab.tsx +│ │ │ │ ├── CraftingTab.test.ts +│ │ │ │ ├── CraftingTab.tsx │ │ │ │ ├── DebugTab.test.ts │ │ │ │ ├── DebugTab.tsx │ │ │ │ ├── DisciplinesTab.tsx @@ -257,6 +262,7 @@ Mana-Loop/ │ │ │ ├── crafting-recipes.ts │ │ │ ├── enchantment-effects.ts │ │ │ ├── enchantment-types.ts +│ │ │ ├── fabricator-recipes.ts │ │ │ └── loot-drops.ts │ │ ├── effects/ │ │ │ ├── discipline-effects.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index e5418f1..eec169e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 GuardianPactsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.GuardianPactsTab }))); 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 = () =>
Loading...
; @@ -249,6 +250,7 @@ export default function ManaLoopGame() { 🗿 Golemancy 📜 Pacts 🏔️ Spire + ⚒️ Crafting @@ -342,6 +344,14 @@ export default function ManaLoopGame() { + + + crafting tab failed to load.}> + }> + + + + diff --git a/src/components/game/tabs/CraftingTab.test.ts b/src/components/game/tabs/CraftingTab.test.ts new file mode 100644 index 0000000..e4a2f2d --- /dev/null +++ b/src/components/game/tabs/CraftingTab.test.ts @@ -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 = {}; + 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); + }); +}); diff --git a/src/components/game/tabs/CraftingTab.tsx b/src/components/game/tabs/CraftingTab.tsx new file mode 100644 index 0000000..83e30d9 --- /dev/null +++ b/src/components/game/tabs/CraftingTab.tsx @@ -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('fabricator'); + + return ( + +
+ {/* Sub-tab bar — same clsx pattern as DisciplinesTab */} +
+ {CRAFTING_SUB_TABS.map(({ key, label, icon: Icon }) => ( + + ))} +
+ + {/* Sub-tab content */} + {activeSubTab === 'fabricator' && } + {activeSubTab === 'enchanter' && } +
+
+ ); +} + +CraftingTab.displayName = 'CraftingTab'; diff --git a/src/components/game/tabs/CraftingTab/EnchanterSubTab.tsx b/src/components/game/tabs/CraftingTab/EnchanterSubTab.tsx new file mode 100644 index 0000000..c4db3ab --- /dev/null +++ b/src/components/game/tabs/CraftingTab/EnchanterSubTab.tsx @@ -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('design'); + + // Shared state for the enchantment flow + const [selectedEquipmentType, setSelectedEquipmentType] = useState(null); + const [selectedEffects, setSelectedEffects] = useState([]); + const [designName, setDesignName] = useState(''); + const [selectedDesign, setSelectedDesign] = useState(null); + const [selectedEquipmentInstance, setSelectedEquipmentInstance] = useState(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 ( +
+ {/* Phase selector */} +
+ {PHASES.map(({ key, label, icon: Icon }) => ( + + ))} +
+ + {/* Phase content */} + {activePhase === 'design' && ( + + )} + + {activePhase === 'prepare' && ( + + )} + + {activePhase === 'apply' && ( + + )} +
+ ); +} + +EnchanterSubTab.displayName = 'EnchanterSubTab'; diff --git a/src/components/game/tabs/CraftingTab/FabricatorSubTab.tsx b/src/components/game/tabs/CraftingTab/FabricatorSubTab.tsx new file mode 100644 index 0000000..04e2458 --- /dev/null +++ b/src/components/game/tabs/CraftingTab/FabricatorSubTab.tsx @@ -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 = { + earth: '⛰️ Earth', + metal: '🔩 Metal', + crystal: '💎 Crystal', + sand: '🏜️ Sand', +}; + +function RecipeCard({ + recipe, + materials, + manaAmount, + onCraft, + isCrafting, +}: { + recipe: FabricatorRecipe; + materials: Record; + manaAmount: number; + onCraft: (recipe: FabricatorRecipe) => void; + isCrafting: boolean; +}) { + const { canCraft, missingMaterials, missingMana } = canCraftRecipe( + recipe, + materials, + manaAmount, + ); + const rarityStyle = LOOT_RARITY_COLORS[recipe.rarity]; + + return ( +
+
+
+
+ {recipe.name} +
+
{recipe.rarity}
+
+ + {MANA_TYPE_LABELS[recipe.manaType] ?? recipe.manaType} + +
+ +
{recipe.description}
+
{recipe.gearTrait}
+ + + +
+
Materials:
+ {Object.entries(recipe.materials).map(([matId, amount]) => { + const available = materials[matId] || 0; + const matDrop = LOOT_DROPS[matId]; + const hasEnough = available >= amount; + + return ( +
+ {matDrop?.name ?? matId} + + {available} / {amount} + +
+ ); + })} + +
+ {MANA_TYPE_LABELS[recipe.manaType]?.split(' ')[1] ?? recipe.manaType} Mana: + = recipe.manaCost ? 'text-green-400' : 'text-red-400'}> + {manaAmount} / {recipe.manaCost} + +
+ +
+ Craft Time: + {recipe.craftTime}h +
+
+ + +
+ ); +} + +export function FabricatorSubTab() { + const [selectedManaType, setSelectedManaType] = useState('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 ( +
+ {/* Mana type filter */} +
+ {availableManaTypes.map((mt) => ( + + ))} +
+ +
+ {/* Recipe list */} + + + + + {MANA_TYPE_LABELS[selectedManaType] ?? selectedManaType} Recipes + + + + {isCrafting ? ( +
+
+ Crafting: {equipmentCraftingProgress.blueprintId} +
+ +
+ + {equipmentCraftingProgress.progress.toFixed(1)}h /{' '} + {equipmentCraftingProgress.required.toFixed(1)}h + + Mana spent: {equipmentCraftingProgress.manaSpent} +
+ +
+ ) : ( + +
+ {filteredRecipes.length === 0 ? ( +
+ +

No recipes for this mana type yet.

+
+ ) : ( + filteredRecipes.map((recipe) => ( + + )) + )} +
+
+ )} +
+
+ + {/* Materials inventory */} + + + + + Materials ({Object.values(lootInventory.materials).reduce((a, b) => a + b, 0)}) + + + + + {Object.keys(lootInventory.materials).length === 0 ? ( +
+ +

No materials collected yet.

+

Defeat floors to gather materials!

+
+ ) : ( +
+ {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 ( +
+
+ {drop.name} +
+
x{count}
+
+ ); + })} +
+ )} +
+
+
+
+
+ ); +} + +FabricatorSubTab.displayName = 'FabricatorSubTab'; diff --git a/src/components/game/tabs/index.ts b/src/components/game/tabs/index.ts index 7d745ba..de791d3 100644 --- a/src/components/game/tabs/index.ts +++ b/src/components/game/tabs/index.ts @@ -12,3 +12,4 @@ export { EquipmentTab } from './EquipmentTab'; export { GolemancyTab } from './GolemancyTab'; export { GuardianPactsTab } from './GuardianPactsTab'; export { SpireSummaryTab } from './SpireSummaryTab'; +export { CraftingTab } from './CraftingTab'; diff --git a/src/lib/game/data/fabricator-recipes.ts b/src/lib/game/data/fabricator-recipes.ts new file mode 100644 index 0000000..04918c2 --- /dev/null +++ b/src/lib/game/data/fabricator-recipes.ts @@ -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; + /** 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, + manaAmount: number, +): { canCraft: boolean; missingMaterials: Record; missingMana: number } { + const missingMaterials: Record = {}; + 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 }; +}