refactor: extract sub-components from monster functions (issue #99)
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:
2026-05-20 18:38:24 +02:00
parent 53b3a94725
commit ce084a61a3
15 changed files with 1765 additions and 1539 deletions
@@ -0,0 +1,306 @@
'use client';
import React from 'react';
import { ELEMENTS } from '@/lib/game/constants';
import type { GuardianDef, GuardianBoon } from '@/lib/game/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Shield, Swords, Clock, Sparkles, Check, Lock, ChevronRight } from 'lucide-react';
import clsx from 'clsx';
// ─── Types ───────────────────────────────────────────────────────────────────
export type GuardianStatus = 'undefeated' | 'defeated' | 'signed';
interface FloorTier {
label: string;
floors: number[];
}
// ─── Guardian Card ───────────────────────────────────────────────────────────
interface GuardianCardProps {
floor: number;
guardian: GuardianDef;
status: GuardianStatus;
canAfford: boolean;
hasSlot: boolean;
isRitualActive: boolean;
ritualProgress: number;
onStartRitual: (floor: number) => void;
}
export const GuardianCard: React.FC<GuardianCardProps> = React.memo(({
floor,
guardian,
status,
canAfford,
hasSlot,
isRitualActive,
ritualProgress,
onStartRitual,
}) => {
const elemDef = ELEMENTS[guardian.element];
const elemColor = elemDef?.color ?? '#888';
const elemSym = elemDef?.sym ?? '';
const statusConfig: Record<GuardianStatus, { label: string; color: string; bg: string }> = {
undefeated: { label: 'Undefeated', color: 'text-gray-400', bg: 'bg-gray-800/50' },
defeated: { label: 'Pact Available', color: 'text-amber-400', bg: 'bg-amber-900/20' },
signed: { label: 'Pact Signed', color: 'text-green-400', bg: 'bg-green-900/20' },
};
const sc = statusConfig[status];
const ritualTime = guardian.pactTime;
const ritualComplete = ritualProgress >= ritualTime;
return (
<Card
className={clsx(
'border transition-colors',
status === 'signed' && 'border-green-600/40',
status === 'defeated' && 'border-amber-600/40',
status === 'undefeated' && 'border-gray-700/60',
)}
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: elemColor }}>
<span>{elemSym}</span>
<span className="truncate">{guardian.name}</span>
</CardTitle>
<div className="text-xs text-gray-500 mt-0.5">Floor {floor} · {elemDef?.name ?? guardian.element}</div>
</div>
<Badge className={clsx('text-[10px] px-1.5 py-0 shrink-0', sc.bg, sc.color)}>
{sc.label}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<GuardianStats guardian={guardian} />
<GuardianBoons guardian={guardian} />
<div className="text-xs text-gray-400">
<span className="text-gray-500">Perk:</span> {guardian.uniquePerk}
</div>
<div className="flex items-center gap-3 text-xs text-gray-400">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{ritualTime}h</span>
</div>
<div>
<span className="text-gray-500">Cost:</span> {guardian.pactCost.toLocaleString()} mana
</div>
</div>
{isRitualActive && (
<RitualProgress ritualProgress={ritualProgress} ritualTime={ritualTime} ritualComplete={ritualComplete} />
)}
{status === 'defeated' && !isRitualActive && (
<PactActionButton
canAfford={canAfford}
hasSlot={hasSlot}
onStartRitual={() => onStartRitual(floor)}
/>
)}
</CardContent>
</Card>
);
});
GuardianCard.displayName = 'GuardianCard';
// ─── Guardian Stats ──────────────────────────────────────────────────────────
function GuardianStats({ guardian }: { guardian: GuardianDef }) {
return (
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="flex items-center gap-1 text-gray-400">
<Shield className="w-3 h-3" />
<span>HP: {guardian.hp.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1 text-gray-400">
<Swords className="w-3 h-3" />
<span>PWR: {guardian.power.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1 text-gray-400">
<Shield className="w-3 h-3" />
<span>ARM: {Math.round((guardian.armor ?? 0) * 100)}%</span>
</div>
</div>
);
}
// ─── Guardian Boons ──────────────────────────────────────────────────────────
function GuardianBoons({ guardian }: { guardian: GuardianDef }) {
return (
<div className="space-y-1">
<div className="text-xs font-medium text-gray-300 flex items-center gap-1">
<Sparkles className="w-3 h-3" /> Boons
</div>
<div className="flex flex-wrap gap-1">
{guardian.boons.map((boon: GuardianBoon, i: number) => (
<span
key={i}
className="px-1.5 py-0.5 text-[10px] rounded border border-gray-600/50 text-gray-300"
>
{boon.desc}
</span>
))}
</div>
</div>
);
}
// ─── Ritual Progress ─────────────────────────────────────────────────────────
function RitualProgress({ ritualProgress, ritualTime, ritualComplete }: { ritualProgress: number; ritualTime: number; ritualComplete: boolean }) {
return (
<div className="space-y-1">
<div className="flex justify-between text-xs">
<span className="text-amber-400">Ritual in progress</span>
<span className="text-gray-400">{ritualProgress}/{ritualTime}h</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-1.5">
<div
className="bg-amber-500 h-1.5 rounded-full transition-all"
style={{ width: `${Math.min(100, (ritualProgress / ritualTime) * 100)}%` }}
/>
</div>
{ritualComplete && (
<div className="text-xs text-green-400 flex items-center gap-1">
<Check className="w-3 h-3" /> Ritual complete pact will be signed on next tick
</div>
)}
</div>
);
}
// ─── Pact Action Button ──────────────────────────────────────────────────────
function PactActionButton({ canAfford, hasSlot, onStartRitual }: { canAfford: boolean; hasSlot: boolean; onStartRitual: () => void }) {
const disabled = !canAfford || !hasSlot;
return (
<button
onClick={onStartRitual}
disabled={disabled}
className={clsx(
'w-full rounded px-3 py-1.5 text-xs font-medium transition-colors flex items-center justify-center gap-1',
!disabled
? 'bg-amber-600/80 text-white hover:bg-amber-500'
: 'bg-gray-700 text-gray-500 cursor-not-allowed',
)}
>
{!canAfford ? (
<><Lock className="w-3 h-3" /> Not enough mana</>
) : !hasSlot ? (
<><Lock className="w-3 h-3" /> No pact slots</>
) : (
<><ChevronRight className="w-3 h-3" /> Begin Pact Ritual</>
)}
</button>
);
}
// ─── Pact Header Summary ────────────────────────────────────────────────────
export function PactHeaderSummary({
signedCount,
pactSlots,
defeatedCount,
cumulativeBoons,
}: {
signedCount: number;
pactSlots: number;
defeatedCount: number;
cumulativeBoons: Record<string, number>;
}) {
return (
<Card className="bg-[var(--bg-panel)] border-[var(--border-subtle)]">
<CardContent className="py-3">
<div className="flex flex-wrap items-center gap-4 text-xs">
<div className="flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-amber-400" />
<span className="text-gray-400">Pact Slots:</span>
<span className="text-gray-200">{signedCount} / {pactSlots}</span>
</div>
<div className="flex items-center gap-1.5">
<Check className="w-3.5 h-3.5 text-green-400" />
<span className="text-gray-400">Signed:</span>
<span className="text-green-400">{signedCount}</span>
</div>
<div className="flex items-center gap-1.5">
<Swords className="w-3.5 h-3.5 text-red-400" />
<span className="text-gray-400">Defeated:</span>
<span className="text-red-400">{defeatedCount}</span>
</div>
</div>
{signedCount > 0 && (
<div className="mt-2 pt-2 border-t border-gray-700/50">
<div className="text-xs text-gray-400 mb-1">Active Boon Effects:</div>
<div className="flex flex-wrap gap-1">
{Object.entries(cumulativeBoons).map(([type, value]) => (
<span
key={type}
className="px-1.5 py-0.5 text-[10px] rounded border border-green-600/30 text-green-300 bg-green-900/20"
>
{type}: +{value}
</span>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
// ─── Tier Filter ─────────────────────────────────────────────────────────────
export function TierFilter({
tiers,
activeTier,
guardianFloors,
onSelectTier,
}: {
tiers: FloorTier[];
activeTier: string;
guardianFloors: number[];
onSelectTier: (tier: string) => void;
}) {
return (
<div className="flex gap-2 flex-wrap">
<button
onClick={() => onSelectTier('all')}
className={clsx(
'rounded px-3 py-1 text-xs font-medium transition-colors',
activeTier === 'all'
? 'bg-amber-600 text-white'
: 'text-gray-400 hover:text-gray-200',
)}
>
All ({guardianFloors.length})
</button>
{tiers.map((tier) => (
<button
key={tier.label}
onClick={() => onSelectTier(tier.label)}
className={clsx(
'rounded px-3 py-1 text-xs font-medium transition-colors',
activeTier === tier.label
? 'bg-amber-600 text-white'
: 'text-gray-400 hover:text-gray-200',
)}
>
{tier.label} ({tier.floors.length})
</button>
))}
</div>
);
}