1c7fc8c551
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
261 lines
9.2 KiB
TypeScript
261 lines
9.2 KiB
TypeScript
'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';
|