Files
Mana-Loop/src/components/game/tabs/CraftingTab/FabricatorSubTab.tsx
T
n8n-gitea 0e1e506213
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
fix: apply Crafting Efficiency cost reduction to all fabrication paths
- Add getCraftingCostReduction() and applyCostReduction() helpers in crafting-fabricator.ts
- Apply cost reduction in deductMaterials() and checkFabricatorCosts()
- Apply cost reduction in startFabricatorCrafting() and cancelEquipmentCrafting() pipeline
- Update canCraftRecipe() in fabricator-recipes.ts to accept costReduction param
- Update FabricatorSubTab and MaterialRecipeCard UIs to display discounted costs
- Spec formula: actualCost = ceil(baseCost × (1 - craftingCostReduction / 100))

Fixes #316
2026-06-07 23:15:55 +02:00

397 lines
15 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, FlaskConical, Hammer, Package, Sparkles, Sword } from 'lucide-react';
import { MaterialRecipeCard } from './MaterialRecipeCard';
import { FABRICATOR_RECIPES, MATERIAL_RECIPES, canCraftRecipe } from '@/lib/game/data/fabricator-recipes';
import { MANA_TYPE_LABELS } from '@/lib/game/data/fabricator-recipe-types';
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';
import { DebugName } from '@/components/game/debug/debug-context';
import { getCraftingCostReduction, applyCostReduction } from '@/lib/game/crafting-fabricator';
const BRANCH_RECIPE_IDS = new Set([
'oakStaff', 'arcanistStaff', 'battlestaff', 'arcanistCirclet', 'arcanistRobe',
'voidCatalyst', 'arcanistPendant',
'crystalBlade', 'arcanistBlade', 'voidBlade', 'battleHelm', 'battleRobe',
'battleBoots', 'combatGauntlets',
]);
function isWizardBranch(recipe: FabricatorRecipe): boolean {
return ['oakStaff', 'arcanistStaff', 'battlestaff', 'arcanistCirclet',
'arcanistRobe', 'voidCatalyst', 'arcanistPendant'].includes(recipe.id);
}
function isPhysicalBranch(recipe: FabricatorRecipe): boolean {
return ['crystalBlade', 'arcanistBlade', 'voidBlade', 'battleHelm',
'battleRobe', 'battleBoots', 'combatGauntlets'].includes(recipe.id);
}
function RecipeCard({
recipe,
materials,
rawMana,
elementalMana,
onCraft,
isCrafting,
costReduction,
}: {
recipe: FabricatorRecipe;
materials: Record<string, number>;
rawMana: number;
elementalMana: Record<string, { current: number; max: number; unlocked: boolean }>;
onCraft: (recipe: FabricatorRecipe) => void;
isCrafting: boolean;
costReduction: number;
}) {
const pool = recipe.manaType === 'raw'
? rawMana
: (elementalMana[recipe.manaType]?.current ?? 0);
const { canCraft } = canCraftRecipe(
recipe,
materials,
pool,
recipe.manaType,
costReduction,
);
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 flex justify-between">
<span>Materials:</span>
{costReduction > 0 && (
<span className="text-cyan-400">-{costReduction}% cost</span>
)}
</div>
{Object.entries(recipe.materials).map(([matId, rawAmount]) => {
const reducedAmount = applyCostReduction(rawAmount, costReduction);
const available = materials[matId] || 0;
const hasEnough = available >= reducedAmount;
const matDrop = LOOT_DROPS[matId];
return (
<div key={matId} className="flex justify-between">
<span>{matDrop?.name ?? matId}</span>
<span className={hasEnough ? 'text-green-400' : 'text-red-400'}>
{available} / {reducedAmount}
{costReduction > 0 && rawAmount !== reducedAmount && (
<span className="text-gray-500 ml-1">(was {rawAmount})</span>
)}
</span>
</div>
);
})}
<div className="flex justify-between mt-2">
<span>{MANA_TYPE_LABELS[recipe.manaType]?.split(' ')[1] ?? recipe.manaType} Mana:</span>
<span className={pool >= recipe.manaCost ? 'text-green-400' : 'text-red-400'}>
{pool} / {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>
);
}
type BranchFilter = 'all' | 'elemental' | 'wizard' | 'physical';
export function FabricatorSubTab() {
const [selectedManaType, setSelectedManaType] = useState<string>('earth');
const [activeSection, setActiveSection] = useState<'equipment' | 'materials'>('equipment');
const [branchFilter, setBranchFilter] = useState<BranchFilter>('all');
const lootInventory = useCraftingStore((s) => s.lootInventory);
const unlockedRecipes = useCraftingStore((s) => s.unlockedRecipes);
const equipmentCraftingProgress = useCraftingStore((s) => s.equipmentCraftingProgress);
const rawMana = useManaStore((s) => s.rawMana);
const elements = useManaStore((s) => s.elements);
const startFabricatorCrafting = useCraftingStore((s) => s.startFabricatorCrafting);
const craftMaterial = useCraftingStore((s) => s.craftMaterial);
const cancelEquipmentCrafting = useCraftingStore((s) => s.cancelEquipmentCrafting);
const availableManaTypes = useMemo(() => {
return [...new Set(FABRICATOR_RECIPES.map((r) => r.manaType))];
}, []);
const filteredRecipes = useMemo(() => {
let recipes = FABRICATOR_RECIPES;
if (branchFilter === 'elemental') {
recipes = recipes.filter(r => !BRANCH_RECIPE_IDS.has(r.id));
} else if (branchFilter === 'wizard') {
recipes = recipes.filter(r => isWizardBranch(r));
} else if (branchFilter === 'physical') {
recipes = recipes.filter(r => isPhysicalBranch(r));
}
return recipes.filter(r => r.manaType === selectedManaType && unlockedRecipes.includes(r.id));
}, [selectedManaType, branchFilter, unlockedRecipes]);
const isCrafting = equipmentCraftingProgress !== null;
const costReduction = getCraftingCostReduction();
const materialRecipes = useMemo(() => MATERIAL_RECIPES, []);
const handleCraft = (recipe: FabricatorRecipe) => {
if (recipe.recipeType === 'material') {
craftMaterial(recipe.id);
} else {
startFabricatorCrafting(recipe.id);
}
};
return (
<DebugName name="FabricatorSubTab">
<div className="space-y-4">
{/* Section toggle: Equipment vs Materials */}
<div className="flex gap-2">
<Button
variant={activeSection === 'equipment' ? 'default' : 'outline'}
size="sm"
onClick={() => setActiveSection('equipment')}
>
<Hammer className="w-3 h-3 mr-1" />
Equipment
</Button>
<Button
variant={activeSection === 'materials' ? 'default' : 'outline'}
size="sm"
onClick={() => setActiveSection('materials')}
>
<FlaskConical className="w-3 h-3 mr-1" />
Materials
</Button>
</div>
{/* Branch filter — only for equipment */}
{activeSection === 'equipment' && (
<>
<div className="flex gap-2 flex-wrap">
<Button
variant={branchFilter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setBranchFilter('all')}
>
<Anvil className="w-3 h-3 mr-1" />
All
</Button>
<Button
variant={branchFilter === 'elemental' ? 'default' : 'outline'}
size="sm"
onClick={() => setBranchFilter('elemental')}
>
<Anvil className="w-3 h-3 mr-1" />
Elemental
</Button>
<Button
variant={branchFilter === 'wizard' ? 'default' : 'outline'}
size="sm"
onClick={() => setBranchFilter('wizard')}
>
<Sparkles className="w-3 h-3 mr-1" />
Wizard
</Button>
<Button
variant={branchFilter === 'physical' ? 'default' : 'outline'}
size="sm"
onClick={() => setBranchFilter('physical')}
>
<Sword className="w-3 h-3 mr-1" />
Physical
</Button>
</div>
{/* 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">
{activeSection === 'equipment' ? (
<><Hammer className="w-4 h-4" />{MANA_TYPE_LABELS[selectedManaType] ?? selectedManaType} Recipes</>
) : (
<><FlaskConical className="w-4 h-4" />Material 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">
{activeSection === 'equipment' ? (
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}
rawMana={rawMana}
elementalMana={elements}
onCraft={handleCraft}
isCrafting={isCrafting}
costReduction={costReduction}
/>
))
)
) : (
materialRecipes.length === 0 ? (
<div className="text-center text-gray-400 py-4">
<FlaskConical className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>No material recipes available.</p>
</div>
) : (
materialRecipes.map((recipe) => (
<MaterialRecipeCard
key={recipe.id}
recipe={recipe}
materials={lootInventory.materials}
rawMana={rawMana}
elementalMana={elements}
onCraft={handleCraft}
costReduction={costReduction}
/>
))
)
)}
</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>
</DebugName>
);
}
FabricatorSubTab.displayName = 'FabricatorSubTab';