feat: recreate Crafting Tab with Fabricator and Enchanter sub-tabs
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:
2026-05-20 02:32:37 +02:00
parent 9882578627
commit 1c7fc8c551
10 changed files with 840 additions and 2 deletions
@@ -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);
});
});
+54
View File
@@ -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';
+1
View File
@@ -12,3 +12,4 @@ export { EquipmentTab } from './EquipmentTab';
export { GolemancyTab } from './GolemancyTab';
export { GuardianPactsTab } from './GuardianPactsTab';
export { SpireSummaryTab } from './SpireSummaryTab';
export { CraftingTab } from './CraftingTab';