0e1e506213
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
- 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
397 lines
15 KiB
TypeScript
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';
|