refactor: extract sub-components from monster functions (issue #99)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
- GuardianPactsTab: extracted GuardianCard, PactHeaderSummary, TierFilter + 5 helper components into guardian-pacts-components.tsx - SpireSummaryTab: extracted TopStatsRow, NextGuardianCard, GuardianRoster, GuardianRosterItem, FloorLegend - PrestigeTab: extracted InsightSummary, MemoriesCard, PactsCard, ResetLoopSection - GameStateDebug: extracted WarningBanner, DisplayOptions, GameResetSection, ManaDebugSection, TimeControlSection, QuickActionsSection - EquipmentCrafter: extracted CraftingProgress, BlueprintCard, BlueprintList, MaterialCard, MaterialsInventory - PactDebug: extracted GuardianPactRow, GuardianPactList - GameStateDebugSection: extracted DisplayOptions, GameResetSection, ManaDebugSection, TimeControlSection, QuickActionsSection - PactDebugSection: extracted GuardianPactRow - SpireCombatPage: extracted useSpireStats hook - page.tsx: extracted GrimoireTab to separate file, useGameDerivedStats hook, TabTriggers, LazyTab wrapper All files now under 400 lines. Build passes. All 639 tests pass.
This commit is contained in:
@@ -9,22 +9,220 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { Package, Sparkles, Trash2, Anvil } from 'lucide-react';
|
||||
import { CRAFTING_RECIPES, canCraftRecipe } from '@/lib/game/data/crafting-recipes';
|
||||
import { LOOT_DROPS, LOOT_RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||
import type { EquipmentInstance, AppliedEnchantment, LootInventory, EquipmentCraftingProgress } from '@/lib/game/types';
|
||||
import type { LootInventory } from '@/lib/game/types';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
import { useCraftingStore, useCombatStore, useManaStore } from '@/lib/game/stores';
|
||||
|
||||
// ─── Crafting Progress ───────────────────────────────────────────────────────
|
||||
|
||||
function CraftingProgress({ progress }: { progress: { blueprintId: string; progress: number; required: number; manaSpent: number } }) {
|
||||
const recipe = CRAFTING_RECIPES[progress.blueprintId];
|
||||
const cancelEquipmentCrafting = useCraftingStore((s) => s.cancelEquipmentCrafting);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-400">
|
||||
Crafting: {recipe?.name}
|
||||
</div>
|
||||
<Progress value={(progress.progress / progress.required) * 100} className="h-3" />
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>{progress.progress.toFixed(1)}h / {progress.required.toFixed(1)}h</span>
|
||||
<span>Mana spent: {fmt(progress.manaSpent)}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={cancelEquipmentCrafting}>Cancel</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blueprint Card ───────────────────────────────────────────────────────────
|
||||
|
||||
function BlueprintCard({ bpId, lootInventory, rawMana, isCrafting }: {
|
||||
bpId: string;
|
||||
lootInventory: LootInventory;
|
||||
rawMana: number;
|
||||
isCrafting: boolean;
|
||||
}) {
|
||||
const recipe = CRAFTING_RECIPES[bpId];
|
||||
if (!recipe) return null;
|
||||
|
||||
const { canCraft, missingMaterials } = canCraftRecipe(recipe, lootInventory.materials, rawMana);
|
||||
const rarityStyle = LOOT_RARITY_COLORS[recipe.rarity];
|
||||
const startCraftingEquipment = useCraftingStore((s) => s.startCraftingEquipment);
|
||||
const currentAction = useCombatStore((s) => s.currentAction);
|
||||
|
||||
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">
|
||||
{recipe.equipmentTypeId ? 'Equipment' : 'Other'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-400 mb-2">{recipe.description}</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 = lootInventory.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 Cost:</span>
|
||||
<span className={rawMana >= recipe.manaCost ? 'text-green-400' : 'text-red-400'}>
|
||||
{fmt(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={() => startCraftingEquipment(bpId)}
|
||||
>
|
||||
{canCraft ? 'Craft Equipment' : 'Missing Resources'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blueprint List ───────────────────────────────────────────────────────────
|
||||
|
||||
function BlueprintList({ lootInventory, rawMana }: { lootInventory: LootInventory; rawMana: number }) {
|
||||
const currentAction = useCombatStore((s) => s.currentAction);
|
||||
|
||||
if (lootInventory.blueprints.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-gray-400 py-4">
|
||||
<Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No blueprints discovered yet.</p>
|
||||
<p className="text-xs mt-1">Defeat guardians to find blueprints!</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-2">
|
||||
{lootInventory.blueprints.map(bpId => (
|
||||
<BlueprintCard
|
||||
key={bpId}
|
||||
bpId={bpId}
|
||||
lootInventory={lootInventory}
|
||||
rawMana={rawMana}
|
||||
isCrafting={currentAction === 'craft'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Material Card ────────────────────────────────────────────────────────────
|
||||
|
||||
function MaterialCard({ matId, count }: { matId: string; count: number }) {
|
||||
const drop = LOOT_DROPS[matId];
|
||||
if (!drop) return null;
|
||||
|
||||
const rarityStyle = LOOT_RARITY_COLORS[drop.rarity];
|
||||
const deleteMaterial = useCraftingStore((s) => s.deleteMaterial);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-2 rounded border bg-gray-800/50 group relative"
|
||||
style={{ borderColor: rarityStyle?.color }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: rarityStyle?.color }}>
|
||||
{drop.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">x{count}</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
||||
onClick={() => deleteMaterial(matId, count)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Materials Inventory ─────────────────────────────────────────────────────
|
||||
|
||||
function MaterialsInventory({ materials }: { materials: Record<string, number> }) {
|
||||
const totalCount = Object.values(materials).reduce((a, b) => a + b, 0);
|
||||
|
||||
return (
|
||||
<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 ({totalCount})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-64">
|
||||
{Object.keys(materials).length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-4">
|
||||
<Sparkles 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(materials).map(([matId, count]) => {
|
||||
if (count <= 0) return null;
|
||||
return <MaterialCard key={matId} matId={matId} count={count} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function EquipmentCrafter() {
|
||||
const lootInventory = useCraftingStore((s) => s.lootInventory);
|
||||
const equipmentCraftingProgress = useCraftingStore((s) => s.equipmentCraftingProgress);
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const currentAction = useCombatStore((s) => s.currentAction);
|
||||
const startCraftingEquipment = useCraftingStore((s) => s.startCraftingEquipment);
|
||||
const cancelEquipmentCrafting = useCraftingStore((s) => s.cancelEquipmentCrafting);
|
||||
const deleteMaterial = useCraftingStore((s) => s.deleteMaterial);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Blueprint Selection */}
|
||||
<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">
|
||||
@@ -34,166 +232,16 @@ export function EquipmentCrafter() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{equipmentCraftingProgress ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-400">
|
||||
Crafting: {CRAFTING_RECIPES[equipmentCraftingProgress.blueprintId]?.name}
|
||||
</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: {fmt(equipmentCraftingProgress.manaSpent)}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={cancelEquipmentCrafting}>Cancel</Button>
|
||||
</div>
|
||||
<CraftingProgress progress={equipmentCraftingProgress} />
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-2">
|
||||
{lootInventory.blueprints.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 blueprints discovered yet.</p>
|
||||
<p className="text-xs mt-1">Defeat guardians to find blueprints!</p>
|
||||
</div>
|
||||
) : (
|
||||
lootInventory.blueprints.map(bpId => {
|
||||
const recipe = CRAFTING_RECIPES[bpId];
|
||||
if (!recipe) return null;
|
||||
|
||||
const { canCraft, missingMaterials, missingMana } = canCraftRecipe(
|
||||
recipe,
|
||||
lootInventory.materials,
|
||||
rawMana
|
||||
);
|
||||
|
||||
const rarityStyle = LOOT_RARITY_COLORS[recipe.rarity];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={bpId}
|
||||
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">
|
||||
{recipe.equipmentTypeId ? 'Equipment' : 'Other'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-400 mb-2">{recipe.description}</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 = lootInventory.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 Cost:</span>
|
||||
<span className={rawMana >= recipe.manaCost ? 'text-green-400' : 'text-red-400'}>
|
||||
{fmt(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 || currentAction === 'craft'}
|
||||
onClick={() => startCraftingEquipment(bpId)}
|
||||
>
|
||||
{canCraft ? 'Craft Equipment' : 'Missing Resources'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<BlueprintList lootInventory={lootInventory} rawMana={rawMana} />
|
||||
)}
|
||||
</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-64">
|
||||
{Object.keys(lootInventory.materials).length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-4">
|
||||
<Sparkles 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 group relative"
|
||||
style={{ borderColor: rarityStyle?.color }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: rarityStyle?.color }}>
|
||||
{drop.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">x{count}</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
||||
onClick={() => deleteMaterial(matId, count)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<MaterialsInventory materials={lootInventory.materials} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EquipmentCrafter.displayName = "EquipmentCrafter";
|
||||
EquipmentCrafter.displayName = 'EquipmentCrafter';
|
||||
|
||||
Reference in New Issue
Block a user