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
@@ -13,6 +13,194 @@ import { useDebug } from '@/components/game/debug/debug-context';
import { useGameStore, useManaStore, useUIStore, useCombatStore } from '@/lib/game/stores';
import { computeMaxMana } from '@/lib/game/stores';
// ─── Display Options ─────────────────────────────────────────────────────────
function DisplayOptions() {
const { showComponentNames, toggleComponentNames } = useDebug();
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
<Eye className="w-4 h-4" />
Display Options
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="show-component-names" className="text-sm">Show Component Names</Label>
<p className="text-xs text-gray-400">
Display component names at the top of each component for debugging
</p>
</div>
<Switch
id="show-component-names"
checked={showComponentNames}
onCheckedChange={toggleComponentNames}
/>
</div>
</CardContent>
</Card>
);
}
// ─── Game Reset Section ──────────────────────────────────────────────────────
function GameResetSection({ confirmReset, onReset }: { confirmReset: boolean; onReset: () => void }) {
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-red-400 text-sm flex items-center gap-2">
<RotateCcw className="w-4 h-4" />
Game Reset
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-gray-400">
Reset all game progress and start fresh. This cannot be undone.
</p>
<Button
className={`w-full ${confirmReset ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-700 hover:bg-gray-600'}`}
onClick={onReset}
>
{confirmReset ? (
<>
<AlertTriangle className="w-4 h-4 mr-2" />
Click Again to Confirm Reset
</>
) : (
<>
<RotateCcw className="w-4 h-4 mr-2" />
Reset Game
</>
)}
</Button>
</CardContent>
</Card>
);
}
// ─── Mana Debug Section ──────────────────────────────────────────────────────
function ManaDebugSection({ rawMana, onAddMana, onFillMana }: {
rawMana: number;
onAddMana: (amount: number) => void;
onFillMana: () => void;
}) {
const maxMana = computeMaxMana(
{ skills: {}, prestigeUpgrades: {}, skillUpgrades: {}, skillTiers: {} }
);
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-blue-400 text-sm flex items-center gap-2">
<Zap className="w-4 h-4" />
Mana Debug
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-xs text-gray-400 mb-2">
Current: {rawMana} / {maxMana || '?'}
</div>
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={() => onAddMana(10)}>
<Zap className="w-3 h-3 mr-1" /> +10
</Button>
<Button size="sm" variant="outline" onClick={() => onAddMana(100)}>
<Zap className="w-3 h-3 mr-1" /> +100
</Button>
<Button size="sm" variant="outline" onClick={() => onAddMana(1000)}>
<Zap className="w-3 h-3 mr-1" /> +1K
</Button>
<Button size="sm" variant="outline" onClick={() => onAddMana(10000)}>
<Zap className="w-3 h-3 mr-1" /> +10K
</Button>
</div>
<Separator className="bg-gray-700" />
<div className="text-xs text-gray-400 mb-2">Fill to max:</div>
<Button size="sm" className="w-full bg-blue-600 hover:bg-blue-700" onClick={onFillMana}>
Fill Mana
</Button>
</CardContent>
</Card>
);
}
// ─── Time Control Section ────────────────────────────────────────────────────
function TimeControlSection({ day, hour, paused, onSetDay, onTogglePause }: {
day: number;
hour: number;
paused: boolean;
onSetDay: (day: number) => void;
onTogglePause: () => void;
}) {
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">
<Clock className="w-4 h-4" />
Time Control
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-xs text-gray-400">
Current: Day {day}, Hour {hour}
</div>
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={() => onSetDay(1)}>Day 1</Button>
<Button size="sm" variant="outline" onClick={() => onSetDay(10)}>Day 10</Button>
<Button size="sm" variant="outline" onClick={() => onSetDay(20)}>Day 20</Button>
<Button size="sm" variant="outline" onClick={() => onSetDay(30)}>Day 30</Button>
</div>
<Separator className="bg-gray-700" />
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={onTogglePause}>
{paused ? '▶ Resume' : '⏸ Pause'}
</Button>
</div>
</CardContent>
</Card>
);
}
// ─── Quick Actions Section ───────────────────────────────────────────────────
function QuickActionsSection({ elements, onUnlockBase, onSkipToFloor, onResetFloorHP }: {
elements: Record<string, { unlocked?: boolean }>;
onUnlockBase: () => void;
onSkipToFloor: () => void;
onResetFloorHP: () => void;
}) {
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
<Zap className="w-4 h-4" />
Quick Actions
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={onUnlockBase}>
Unlock All Base Elements
</Button>
<Button size="sm" variant="outline" onClick={onSkipToFloor}>
Skip to Floor 100
</Button>
<Button size="sm" variant="outline" onClick={onResetFloorHP}>
Reset Floor HP
</Button>
</div>
</CardContent>
</Card>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function GameStateDebugSection() {
const [confirmReset, setConfirmReset] = useState(false);
const { showComponentNames, toggleComponentNames } = useDebug();
@@ -22,7 +210,6 @@ export function GameStateDebugSection() {
const hour = useGameStore((s) => s.hour);
const paused = useUIStore((s) => s.paused);
const togglePause = useUIStore((s) => s.togglePause);
const resetGame = useGameStore((s) => s.resetGame);
const gatherMana = useGameStore((s) => s.gatherMana);
const debugSetFloor = useCombatStore((s) => s.debugSetFloor);
@@ -46,190 +233,42 @@ export function GameStateDebugSection() {
}
};
const getMaxMana = () => {
return computeMaxMana(
const handleFillMana = () => {
const maxMana = computeMaxMana(
{ skills: {}, prestigeUpgrades: {}, skillUpgrades: {}, skillTiers: {} }
);
) || 100;
useManaStore.setState((s) => ({ rawMana: Math.max(s.rawMana, maxMana) }));
};
const handleSetDay = (d: number) => {
useGameStore.setState({ day: d, hour: 0 });
};
const handleUnlockBase = () => {
['fire', 'water', 'air', 'earth', 'light', 'dark', 'death'].forEach(e => {
if (!elements[e]?.unlocked) {
unlockElement(e, 0);
}
});
};
return (
<div className="space-y-4">
{/* Display Options */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
<Eye className="w-4 h-4" />
Display Options
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="show-component-names" className="text-sm">Show Component Names</Label>
<p className="text-xs text-gray-400">
Display component names at the top of each component for debugging
</p>
</div>
<Switch
id="show-component-names"
checked={showComponentNames}
onCheckedChange={toggleComponentNames}
/>
</div>
</CardContent>
</Card>
<DisplayOptions />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Game Reset */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-red-400 text-sm flex items-center gap-2">
<RotateCcw className="w-4 h-4" />
Game Reset
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-gray-400">
Reset all game progress and start fresh. This cannot be undone.
</p>
<Button
className={`w-full ${confirmReset ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-700 hover:bg-gray-600'}`}
onClick={handleReset}
>
{confirmReset ? (
<>
<AlertTriangle className="w-4 h-4 mr-2" />
Click Again to Confirm Reset
</>
) : (
<>
<RotateCcw className="w-4 h-4 mr-2" />
Reset Game
</>
)}
</Button>
</CardContent>
</Card>
{/* Mana Debug */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-blue-400 text-sm flex items-center gap-2">
<Zap className="w-4 h-4" />
Mana Debug
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-xs text-gray-400 mb-2">
Current: {rawMana} / {getMaxMana() || '?'}
</div>
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={() => handleAddMana(10)}>
<Zap className="w-3 h-3 mr-1" /> +10
</Button>
<Button size="sm" variant="outline" onClick={() => handleAddMana(100)}>
<Zap className="w-3 h-3 mr-1" /> +100
</Button>
<Button size="sm" variant="outline" onClick={() => handleAddMana(1000)}>
<Zap className="w-3 h-3 mr-1" /> +1K
</Button>
<Button size="sm" variant="outline" onClick={() => handleAddMana(10000)}>
<Zap className="w-3 h-3 mr-1" /> +10K
</Button>
</div>
<Separator className="bg-gray-700" />
<div className="text-xs text-gray-400 mb-2">Fill to max:</div>
<Button
size="sm"
className="w-full bg-blue-600 hover:bg-blue-700"
onClick={() => {
const max = getMaxMana() || 100;
useManaStore.setState((s) => ({ rawMana: Math.max(s.rawMana, max) }));
}}
>
Fill Mana
</Button>
</CardContent>
</Card>
{/* Time Control */}
<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">
<Clock className="w-4 h-4" />
Time Control
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-xs text-gray-400">
Current: Day {day}, Hour {hour}
</div>
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={() => useGameStore.setState({ day: 1, hour: 0 })}>
Day 1
</Button>
<Button size="sm" variant="outline" onClick={() => useGameStore.setState({ day: 10, hour: 0 })}>
Day 10
</Button>
<Button size="sm" variant="outline" onClick={() => useGameStore.setState({ day: 20, hour: 0 })}>
Day 20
</Button>
<Button size="sm" variant="outline" onClick={() => useGameStore.setState({ day: 30, hour: 0 })}>
Day 30
</Button>
</div>
<Separator className="bg-gray-700" />
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={togglePause}>
{paused ? '▶ Resume' : '⏸ Pause'}
</Button>
</div>
</CardContent>
</Card>
{/* Quick Actions */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
<Zap className="w-4 h-4" />
Quick Actions
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onClick={() => {
['fire', 'water', 'air', 'earth', 'light', 'dark', 'death'].forEach(e => {
if (!elements[e]?.unlocked) {
unlockElement(e, 0);
}
});
}}
>
Unlock All Base Elements
</Button>
<Button
size="sm"
variant="outline"
onClick={() => debugSetFloor?.(100)}
>
Skip to Floor 100
</Button>
<Button
size="sm"
variant="outline"
onClick={() => resetFloorHP?.()}
>
Reset Floor HP
</Button>
</div>
</CardContent>
</Card>
<GameResetSection confirmReset={confirmReset} onReset={handleReset} />
<ManaDebugSection rawMana={rawMana} onAddMana={handleAddMana} onFillMana={handleFillMana} />
<TimeControlSection day={day} hour={hour} paused={paused} onSetDay={handleSetDay} onTogglePause={togglePause} />
<QuickActionsSection
elements={elements}
onUnlockBase={handleUnlockBase}
onSkipToFloor={() => debugSetFloor?.(100)}
onResetFloorHP={() => resetFloorHP?.()}
/>
</div>
</div>
);
}
GameStateDebugSection.displayName = "GameStateDebugSection";
GameStateDebugSection.displayName = 'GameStateDebugSection';
@@ -6,6 +6,51 @@ import { Bug } from 'lucide-react';
import { usePrestigeStore, useManaStore, useUIStore, useGameStore } from '@/lib/game/stores';
import { GUARDIANS, ELEMENTS } from '@/lib/game/constants';
// ─── Guardian Pact Row ───────────────────────────────────────────────────────
function GuardianPactRow({ floor, isSigned, onForceSign, onRemove }: {
floor: number;
isSigned: boolean;
onForceSign: () => void;
onRemove: () => void;
}) {
const guardian = GUARDIANS[floor];
return (
<div
className={`p-2 rounded border flex items-center justify-between ${
isSigned ? 'border-green-600/50 bg-green-900/20' : 'border-gray-700'
}`}
style={{ borderColor: isSigned ? undefined : guardian.color, borderWidth: '1px' }}
>
<div>
<div className="text-sm font-semibold" style={{ color: guardian.color }}>
{guardian.name}
</div>
<div className="text-xs text-gray-400">
Floor {floor} | {guardian.pact}x multiplier
</div>
<div className="text-xs text-gray-500">
Element: {ELEMENTS[guardian.element]?.name || guardian.element}
</div>
</div>
<div className="flex gap-1">
{isSigned ? (
<Button size="sm" variant="destructive" onClick={onRemove} className="text-xs">
Remove
</Button>
) : (
<Button size="sm" variant="default" onClick={onForceSign} className="text-xs bg-amber-600 hover:bg-amber-700">
Force Sign
</Button>
)}
</div>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function PactDebugSection() {
const signedPacts = usePrestigeStore((s) => s.signedPacts);
const signedPactDetails = usePrestigeStore((s) => s.signedPactDetails);
@@ -99,53 +144,15 @@ export function PactDebugSection() {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{guardianFloors.map((floor) => {
const guardian = GUARDIANS[floor];
const isSigned = signedPacts.includes(floor);
return (
<div
key={floor}
className={`p-2 rounded border flex items-center justify-between ${
isSigned ? 'border-green-600/50 bg-green-900/20' : 'border-gray-700'
}`}
style={{ borderColor: isSigned ? undefined : guardian.color, borderWidth: '1px' }}
>
<div>
<div className="text-sm font-semibold" style={{ color: guardian.color }}>
{guardian.name}
</div>
<div className="text-xs text-gray-400">
Floor {floor} | {guardian.pact}x multiplier
</div>
<div className="text-xs text-gray-500">
Element: {ELEMENTS[guardian.element]?.name || guardian.element}
</div>
</div>
<div className="flex gap-1">
{isSigned ? (
<Button
size="sm"
variant="destructive"
onClick={() => removePactHandler(floor)}
className="text-xs"
>
Remove
</Button>
) : (
<Button
size="sm"
variant="default"
onClick={() => forcePact(floor)}
className="text-xs bg-amber-600 hover:bg-amber-700"
>
Force Sign
</Button>
)}
</div>
</div>
);
})}
{guardianFloors.map((floor) => (
<GuardianPactRow
key={floor}
floor={floor}
isSigned={signedPacts.includes(floor)}
onForceSign={() => forcePact(floor)}
onRemove={() => removePactHandler(floor)}
/>
))}
</div>
<div className="text-xs text-gray-400 pt-2 border-t border-gray-700">
@@ -158,4 +165,4 @@ export function PactDebugSection() {
);
}
PactDebugSection.displayName = "PactDebugSection";
PactDebugSection.displayName = 'PactDebugSection';
+22 -250
View File
@@ -5,14 +5,14 @@ import { useShallow } from 'zustand/react/shallow';
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
import { useManaStore } from '@/lib/game/stores/manaStore';
import { useUIStore } from '@/lib/game/stores/uiStore';
import { GUARDIANS, ELEMENTS } from '@/lib/game/constants';
import type { GuardianDef, GuardianBoon } from '@/lib/game/types';
import { GUARDIANS } from '@/lib/game/constants';
import { DebugName } from '@/components/game/debug/debug-context';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Shield, Swords, Clock, Sparkles, Check, Lock, ChevronRight } from 'lucide-react';
import clsx from 'clsx';
import {
GuardianCard,
PactHeaderSummary,
TierFilter,
} from './guardian-pacts-components';
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -24,172 +24,6 @@ function getGuardianStatus(floor: number, defeated: number[], signed: number[]):
return 'undefeated';
}
function formatHours(hours: number): string {
return `${hours}h`;
}
// ─── Guardian Card ───────────────────────────────────────────────────────────
interface GuardianCardProps {
floor: number;
guardian: GuardianDef;
status: GuardianStatus;
canAfford: boolean;
hasSlot: boolean;
isRitualActive: boolean;
ritualProgress: number;
onStartRitual: (floor: number) => void;
}
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">
{/* Stats */}
<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>
{/* Boons */}
<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>
{/* Unique Perk */}
<div className="text-xs text-gray-400">
<span className="text-gray-500">Perk:</span> {guardian.uniquePerk}
</div>
{/* Pact Cost */}
<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>{formatHours(guardian.pactTime)}</span>
</div>
<div>
<span className="text-gray-500">Cost:</span> {guardian.pactCost.toLocaleString()} mana
</div>
</div>
{/* Ritual Progress */}
{isRitualActive && (
<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>
)}
{/* Action Button */}
{status === 'defeated' && !isRitualActive && (
<button
onClick={() => onStartRitual(floor)}
disabled={!canAfford || !hasSlot}
className={clsx(
'w-full rounded px-3 py-1.5 text-xs font-medium transition-colors flex items-center justify-center gap-1',
canAfford && hasSlot
? '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>
)}
</CardContent>
</Card>
);
});
GuardianCard.displayName = 'GuardianCard';
// ─── Floor Tier Groups ──────────────────────────────────────────────────────
interface FloorTier {
label: string;
floors: number[];
@@ -263,7 +97,6 @@ export const GuardianPactsTab: React.FC = () => {
}
}, [startPactRitual, rawMana, addLog]);
// Cumulative boon summary from signed pacts
const cumulativeBoons = useMemo(() => {
const boonMap: Record<string, number> = {};
for (const floor of signedPacts) {
@@ -287,95 +120,34 @@ export const GuardianPactsTab: React.FC = () => {
return (
<DebugName name="GuardianPactsTab">
<div className="space-y-4">
{/* Header Summary */}
<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">{signedPacts.length} / {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">{signedPacts.length}</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">{defeatedGuardians.length}</span>
</div>
</div>
<PactHeaderSummary
signedCount={signedPacts.length}
pactSlots={pactSlots}
defeatedCount={defeatedGuardians.length}
cumulativeBoons={cumulativeBoons}
/>
{/* Cumulative Boons */}
{signedPacts.length > 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>
<TierFilter
tiers={tiers}
activeTier={activeTier}
guardianFloors={guardianFloors}
onSelectTier={setActiveTier}
/>
{/* Tier Filter */}
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setActiveTier('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={() => setActiveTier(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>
{/* Guardian Cards */}
<ScrollArea className="h-[500px] rounded border border-gray-700 p-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{filteredFloors.map((floor) => {
const guardian = GUARDIANS[floor];
if (!guardian) return null;
const status = getGuardianStatus(floor, defeatedGuardians, signedPacts);
const isRitualActive = pactRitualFloor === floor;
const hasSlot = signedPacts.length < pactSlots;
const canAfford = rawMana >= guardian.pactCost;
return (
<GuardianCard
key={floor}
floor={floor}
guardian={guardian}
status={status}
canAfford={canAfford}
hasSlot={hasSlot}
isRitualActive={isRitualActive}
status={getGuardianStatus(floor, defeatedGuardians, signedPacts)}
canAfford={rawMana >= guardian.pactCost}
hasSlot={signedPacts.length < pactSlots}
isRitualActive={pactRitualFloor === floor}
ritualProgress={pactRitualProgress}
onStartRitual={handleStartRitual}
/>
+147 -107
View File
@@ -23,6 +23,101 @@ import {
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
// ─── Stat Cell ────────────────────────────────────────────────────────────────
function PrestigeStatCell({ value, label, color }: { value: string | number; label: string; color: string }) {
return (
<div className="text-center">
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className="text-xs text-gray-400 mt-0.5">{label}</div>
</div>
);
}
// ─── Insight Summary ──────────────────────────────────────────────────────────
function InsightSummary({ insight, totalInsight, loopCount, loopInsight }: {
insight: number;
totalInsight: number;
loopCount: number;
loopInsight: number;
}) {
return (
<Card className="bg-gray-900/60 border-gray-700">
<CardContent className="py-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<PrestigeStatCell value={fmt(insight)} label="Insight Available" color="text-amber-400" />
<PrestigeStatCell value={fmt(totalInsight)} label="Total Insight Earned" color="text-gray-200" />
<PrestigeStatCell value={loopCount} label="Loops Completed" color="text-gray-200" />
<PrestigeStatCell value={fmt(loopInsight)} label="This Loop's Insight" color="text-purple-400" />
</div>
</CardContent>
</Card>
);
}
// ─── Memories Card ────────────────────────────────────────────────────────────
function MemoriesCard({ memories, memorySlots }: { memories: { skillId: string; level: number; tier: number }[]; memorySlots: number }) {
return (
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="🧠 Memories" />
<CardContent className="pt-0">
<p className="text-xs text-gray-400 mb-2">
Skills carried between loops. Slots: {memories.length}/{memorySlots}
</p>
{memories.length === 0 ? (
<p className="text-xs text-gray-500 italic">No memories stored yet.</p>
) : (
<div className="space-y-1">
{memories.map((m) => (
<div key={m.skillId} className="text-xs text-gray-300 flex justify-between">
<span>{m.skillId}</span>
<span className="text-gray-500">Lv.{m.level} T{m.tier}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
// ─── Pacts Card ───────────────────────────────────────────────────────────────
function PactsCard({ signedPacts, pactSlots, defeatedGuardians }: {
signedPacts: number[];
pactSlots: number;
defeatedGuardians: number[];
}) {
return (
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="📜 Pacts" />
<CardContent className="pt-0">
<p className="text-xs text-gray-400 mb-2">
Guardian pacts signed. Slots: {signedPacts.length}/{pactSlots}
</p>
{defeatedGuardians.length > 0 && (
<p className="text-xs text-gray-500 mb-1">
Defeated guardians: {defeatedGuardians.map((f) => `F${f}`).join(', ')}
</p>
)}
{signedPacts.length === 0 ? (
<p className="text-xs text-gray-500 italic">No pacts signed yet.</p>
) : (
<div className="space-y-1">
{signedPacts.map((f) => (
<div key={f} className="text-xs text-green-400">
Floor {f} Pact signed
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
// ─── Upgrade Card ─────────────────────────────────────────────────────────────
interface UpgradeCardProps {
@@ -72,6 +167,49 @@ function UpgradeCard({ id, name, desc, max, cost, currentLevel, insight, onPurch
);
}
// ─── Reset Loop Section ──────────────────────────────────────────────────────
function ResetLoopSection({ loopInsight, onReset }: { loopInsight: number; onReset: () => void }) {
return (
<Card className="bg-gray-900/60 border-red-900/50">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-red-400">Reset Loop</h3>
<p className="text-xs text-gray-400 mt-1">
End the current loop and gain {fmt(loopInsight)} insight. Your prestige upgrades, memories, and pacts are preserved.
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" className="flex-shrink-0 ml-4">
Reset Loop
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset the Loop?</AlertDialogTitle>
<AlertDialogDescription>
This will end your current loop and award you <strong className="text-amber-400">{fmt(loopInsight)} insight</strong>.
Your prestige upgrades, memories, and pacts will be preserved.
<br /><br />
Day, hour, mana, floor progress, and combat state will be reset.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onReset}>
Confirm Reset
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function PrestigeTab() {
@@ -130,80 +268,18 @@ export function PrestigeTab() {
return (
<DebugName name="PrestigeTab">
<div className="space-y-4">
{/* Insight & Loop Summary */}
<Card className="bg-gray-900/60 border-gray-700">
<CardContent className="py-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-amber-400">{fmt(insight)}</div>
<div className="text-xs text-gray-400 mt-0.5">Insight Available</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-200">{fmt(totalInsight)}</div>
<div className="text-xs text-gray-400 mt-0.5">Total Insight Earned</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-200">{loopCount}</div>
<div className="text-xs text-gray-400 mt-0.5">Loops Completed</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-400">{fmt(loopInsight)}</div>
<div className="text-xs text-gray-400 mt-0.5">This Loop&apos;s Insight</div>
</div>
</div>
</CardContent>
</Card>
<InsightSummary
insight={insight}
totalInsight={totalInsight}
loopCount={loopCount}
loopInsight={loopInsight}
/>
{/* Memories & Pacts */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="🧠 Memories" />
<CardContent className="pt-0">
<p className="text-xs text-gray-400 mb-2">
Skills carried between loops. Slots: {memories.length}/{memorySlots}
</p>
{memories.length === 0 ? (
<p className="text-xs text-gray-500 italic">No memories stored yet.</p>
) : (
<div className="space-y-1">
{memories.map((m) => (
<div key={m.skillId} className="text-xs text-gray-300 flex justify-between">
<span>{m.skillId}</span>
<span className="text-gray-500">Lv.{m.level} T{m.tier}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="📜 Pacts" />
<CardContent className="pt-0">
<p className="text-xs text-gray-400 mb-2">
Guardian pacts signed. Slots: {signedPacts.length}/{pactSlots}
</p>
{defeatedGuardians.length > 0 && (
<p className="text-xs text-gray-500 mb-1">
Defeated guardians: {defeatedGuardians.map((f) => `F${f}`).join(', ')}
</p>
)}
{signedPacts.length === 0 ? (
<p className="text-xs text-gray-500 italic">No pacts signed yet.</p>
) : (
<div className="space-y-1">
{signedPacts.map((f) => (
<div key={f} className="text-xs text-green-400">
Floor {f} Pact signed
</div>
))}
</div>
)}
</CardContent>
</Card>
<MemoriesCard memories={memories} memorySlots={memorySlots} />
<PactsCard signedPacts={signedPacts} pactSlots={pactSlots} defeatedGuardians={defeatedGuardians} />
</div>
{/* Prestige Upgrades */}
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="⬆️ Prestige Upgrades" />
<CardContent className="pt-0">
@@ -227,43 +303,7 @@ export function PrestigeTab() {
</CardContent>
</Card>
{/* Reset Loop */}
<Card className="bg-gray-900/60 border-red-900/50">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-red-400">Reset Loop</h3>
<p className="text-xs text-gray-400 mt-1">
End the current loop and gain {fmt(loopInsight)} insight. Your prestige upgrades, memories, and pacts are preserved.
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" className="flex-shrink-0 ml-4">
Reset Loop
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset the Loop?</AlertDialogTitle>
<AlertDialogDescription>
This will end your current loop and award you <strong className="text-amber-400">{fmt(loopInsight)} insight</strong>.
Your prestige upgrades, memories, and pacts will be preserved.
<br /><br />
Day, hour, mana, floor progress, and combat state will be reset.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleResetLoop}>
Confirm Reset
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
<ResetLoopSection loopInsight={loopInsight} onReset={handleResetLoop} />
</div>
</DebugName>
);
@@ -5,82 +5,21 @@ import { useShallow } from 'zustand/react/shallow';
import { useCombatStore, useManaStore, usePrestigeStore, fmt, computeMaxMana, computeRegen } from '@/lib/game/stores';
import { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects';
import { getUnifiedEffects } from '@/lib/game/effects';
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
import { useCraftingStore } from '@/lib/game/stores/craftingStore';
import { GUARDIANS } from '@/lib/game/constants';
import { getExtendedGuardian, isGuardianFloor } from '@/lib/game/data/guardian-encounters';
import { getRoomsForFloor, generateSpireFloorState, calcInsight } from '@/lib/game/utils/spire-utils';
import { getRoomsForFloor, generateSpireFloorState } from '@/lib/game/utils/spire-utils';
import { SpireHeader } from './SpireHeader';
import { RoomDisplay } from './RoomDisplay';
import { SpireCombatControls } from './SpireCombatControls';
import { SpireActivityLog } from './SpireActivityLog';
import { SpireManaDisplay } from './SpireManaDisplay';
export function SpireCombatPage() {
const [mounted, setMounted] = useState(false);
const [roomsCleared, setRoomsCleared] = useState(0);
// ─── Derived Stats Hook ──────────────────────────────────────────────────────
// Combat store
const {
currentFloor,
floorHP,
floorMaxHP,
castProgress,
clearedFloors,
isDescending,
currentRoom,
activityLog,
setCurrentRoom,
setFloorHP,
setClearedFloor,
climbDownFloor,
exitSpireMode,
startClimbUp,
startClimbDown,
addActivityLog,
processCombatTick,
setAction,
} = useCombatStore(useShallow((s) => ({
currentFloor: s.currentFloor,
floorHP: s.floorHP,
floorMaxHP: s.floorMaxHP,
castProgress: s.castProgress,
clearedFloors: s.clearedFloors,
isDescending: s.isDescending,
currentRoom: s.currentRoom,
activityLog: s.activityLog,
setCurrentRoom: s.setCurrentRoom,
setFloorHP: s.setFloorHP,
setClearedFloor: s.setClearedFloor,
climbDownFloor: s.climbDownFloor,
exitSpireMode: s.exitSpireMode,
startClimbUp: s.startClimbUp,
startClimbDown: s.startClimbDown,
addActivityLog: s.addActivityLog,
processCombatTick: s.processCombatTick,
setAction: s.setAction,
})));
// Mana store
const { rawMana, elements } = useManaStore(useShallow((s) => ({
rawMana: s.rawMana,
elements: s.elements,
})));
// Prestige store
const { prestigeUpgrades, insight } = usePrestigeStore(useShallow((s) => ({
prestigeUpgrades: s.prestigeUpgrades,
insight: s.insight,
})));
// Crafting store for equipment effects
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
// Discipline effects
function useSpireStats(prestigeUpgrades: Record<string, number>, equippedInstances: unknown[], equipmentInstances: unknown[]) {
const disciplineEffects = computeDisciplineEffects();
// Compute derived stats
const upgradeEffects = getUnifiedEffects({
skillUpgrades: {},
skillTiers: {},
@@ -103,10 +42,70 @@ export function SpireCombatPage() {
attunements: {},
}, upgradeEffects, disciplineEffects);
// Total rooms for current floor
return { maxMana, baseRegen };
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function SpireCombatPage() {
const [mounted, setMounted] = useState(false);
const [roomsCleared, setRoomsCleared] = useState(0);
const {
currentFloor,
floorHP,
floorMaxHP,
castProgress,
clearedFloors,
isDescending,
currentRoom,
activityLog,
setCurrentRoom,
setFloorHP,
setClearedFloor,
climbDownFloor,
exitSpireMode,
startClimbUp,
startClimbDown,
addActivityLog,
setAction,
} = useCombatStore(useShallow((s) => ({
currentFloor: s.currentFloor,
floorHP: s.floorHP,
floorMaxHP: s.floorMaxHP,
castProgress: s.castProgress,
clearedFloors: s.clearedFloors,
isDescending: s.isDescending,
currentRoom: s.currentRoom,
activityLog: s.activityLog,
setCurrentRoom: s.setCurrentRoom,
setFloorHP: s.setFloorHP,
setClearedFloor: s.setClearedFloor,
climbDownFloor: s.climbDownFloor,
exitSpireMode: s.exitSpireMode,
startClimbUp: s.startClimbUp,
startClimbDown: s.startClimbDown,
addActivityLog: s.addActivityLog,
setAction: s.setAction,
})));
const { rawMana, elements } = useManaStore(useShallow((s) => ({
rawMana: s.rawMana,
elements: s.elements,
})));
const { prestigeUpgrades, insight } = usePrestigeStore(useShallow((s) => ({
prestigeUpgrades: s.prestigeUpgrades,
insight: s.insight,
})));
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
const { maxMana, baseRegen } = useSpireStats(prestigeUpgrades, equippedInstances, equipmentInstances);
const totalRooms = useMemo(() => getRoomsForFloor(currentFloor), [currentFloor]);
// Initialize room on floor change
useEffect(() => {
setMounted(true);
setRoomsCleared(0);
@@ -115,12 +114,10 @@ export function SpireCombatPage() {
setAction('climb');
}, [currentFloor, totalRooms, setCurrentRoom, setAction]);
// Handle room/floor transitions
const handleRoomCleared = () => {
const nextRoomIndex = roomsCleared + 1;
if (nextRoomIndex >= totalRooms) {
// Floor cleared
const wasGuardian = isGuardianFloor(currentFloor);
setClearedFloor(currentFloor, true);
@@ -138,30 +135,26 @@ export function SpireCombatPage() {
floor: currentFloor,
});
// Auto-advance to next floor
const newFloor = currentFloor + 1;
const newTotalRooms = getRoomsForFloor(newFloor);
const newRoom = generateSpireFloorState(newFloor, 0, newTotalRooms);
setCurrentRoom(newRoom);
setFloorHP(floorMaxHP); // Reset HP for new floor
setFloorHP(floorMaxHP);
setClearedFloor(currentFloor, true);
setRoomsCleared(0);
} else {
// Next room on same floor
const newRoom = generateSpireFloorState(currentFloor, nextRoomIndex, totalRooms);
setCurrentRoom(newRoom);
setRoomsCleared(nextRoomIndex);
}
};
// Handle climb up
const handleClimbUp = () => {
startClimbUp();
addActivityLog('floor_transition', `⬆️ Climbing to floor ${currentFloor + 1}...`);
};
// Handle climb down
const handleClimbDown = () => {
if (currentFloor <= 1) return;
startClimbDown();
@@ -170,7 +163,6 @@ export function SpireCombatPage() {
addActivityLog('floor_transition', `⬇️ Descending to floor ${currentFloor - 1}...`);
};
// Handle exit spire
const handleExitSpire = () => {
exitSpireMode();
addActivityLog('floor_transition', '🚪 Exited the Spire.');
@@ -186,7 +178,6 @@ export function SpireCombatPage() {
return (
<div className="min-h-screen bg-gray-950 flex flex-col">
{/* Compact header */}
<header className="sticky top-0 z-50 bg-gray-900/95 border-b border-gray-800 px-4 py-2">
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold text-amber-400 tracking-wider">🏔 SPIRE</h1>
@@ -196,9 +187,7 @@ export function SpireCombatPage() {
</div>
</header>
{/* Main content */}
<main className="flex-1 p-4 space-y-4 max-w-7xl mx-auto w-full">
{/* Top section: Header + Mana */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2">
<SpireHeader
@@ -218,7 +207,6 @@ export function SpireCombatPage() {
</div>
</div>
{/* Middle section: Room + Controls */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2">
<RoomDisplay floorState={currentRoom} floor={currentFloor} />
@@ -228,7 +216,6 @@ export function SpireCombatPage() {
</div>
</div>
{/* Bottom: Activity Log */}
<SpireActivityLog activityLog={activityLog} maxEntries={20} />
</main>
</div>
+217 -178
View File
@@ -45,7 +45,6 @@ function FloorProgressBar({ maxFloor, clearedFloors }: { maxFloor: number; clear
const totalFloors = Math.min(maxFloor, 100);
const clearedSet = new Set(Object.entries(clearedFloors).filter(([, v]) => v).map(([k]) => Number(k)));
// Group floors into rows of 10 for display
const rows: number[][] = [];
for (let i = 0; i < totalFloors; i += 10) {
rows.push(Array.from({ length: 10 }, (_, j) => i + j + 1).filter((f) => f <= totalFloors));
@@ -88,23 +87,219 @@ function FloorProgressBar({ maxFloor, clearedFloors }: { maxFloor: number; clear
})}
</div>
))}
<div className="flex items-center gap-3 mt-2 text-[10px] text-gray-500">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-emerald-600/60 border border-gray-700" />
<span>Cleared</span>
<FloorLegend />
</div>
);
}
function FloorLegend() {
return (
<div className="flex items-center gap-3 mt-2 text-[10px] text-gray-500">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-emerald-600/60 border border-gray-700" />
<span>Cleared</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gray-800 border border-gray-700" />
<span>Uncleared</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gray-800 border border-amber-500" />
<span>Guardian</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-amber-600/60 border border-amber-400" />
<span>Current</span>
</div>
</div>
);
}
// ─── Top Stats Row ───────────────────────────────────────────────────────────
function TopStatsRow({ maxFloorReached, totalFloorsCleared, defeatedCount, insight }: {
maxFloorReached: number;
totalFloorsCleared: number;
defeatedCount: number;
insight: number;
}) {
return (
<Card className="bg-gray-900/60 border-gray-700">
<CardContent className="py-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCell value={maxFloorReached} label="Max Floor Reached" color="text-amber-400" />
<StatCell value={totalFloorsCleared} label="Floors Cleared" color="text-gray-200" />
<StatCell value={defeatedCount} label="Guardians Defeated" color="text-emerald-400" />
<StatCell value={fmt(insight)} label="Insight Earned" color="text-purple-400" />
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gray-800 border border-gray-700" />
<span>Uncleared</span>
</CardContent>
</Card>
);
}
function StatCell({ value, label, color }: { value: number | string; label: string; color: string }) {
return (
<div className="text-center">
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className="text-xs text-gray-400 mt-0.5">{label}</div>
</div>
);
}
// ─── Next Guardian Card ──────────────────────────────────────────────────────
function NextGuardianCard({ nextGuardian, nextGuardianData }: { nextGuardian: number; nextGuardianData: (typeof GUARDIANS)[number] }) {
const counterElement = getCounterElement(nextGuardianData.element);
const nextFloorElement = FLOOR_ELEM_CYCLE[(nextGuardian - 1) % FLOOR_ELEM_CYCLE.length];
return (
<Card className="bg-gray-900/60 border-amber-800/40">
<SectionHeader
title={`🛡️ Next Guardian — Floor ${nextGuardian}`}
className="text-amber-400"
/>
<CardContent className="pt-0 space-y-3">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-lg font-bold"
style={{
backgroundColor: `${nextGuardianData.color}20`,
border: `2px solid ${nextGuardianData.color}`,
color: nextGuardianData.color,
}}
>
{nextGuardian}
</div>
<div>
<div className="font-semibold text-gray-100">{nextGuardianData.name}</div>
<div className="flex items-center gap-2 mt-0.5">
<Badge
variant="outline"
className="text-xs"
style={{ borderColor: getElementColor(nextGuardianData.element), color: getElementColor(nextGuardianData.element) }}
>
{nextGuardianData.element}
</Badge>
<span className="text-xs text-gray-500">HP: {fmt(nextGuardianData.hp)}</span>
{nextGuardianData.armor && (
<span className="text-xs text-gray-500">
Armor: {Math.round(nextGuardianData.armor * 100)}%
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gray-800 border border-amber-500" />
<span>Guardian</span>
<PreparationTips
counterElement={counterElement}
nextFloorElement={nextFloorElement}
hasHighArmor={!!(nextGuardianData.armor && nextGuardianData.armor > 0.15)}
/>
</CardContent>
</Card>
);
}
function PreparationTips({ counterElement, nextFloorElement, hasHighArmor }: { counterElement: string | null; nextFloorElement: string | null; hasHighArmor: boolean }) {
return (
<div className="bg-gray-800/50 rounded-lg p-3 space-y-2">
<div className="text-xs font-medium text-gray-300">Recommended Preparation:</div>
<div className="text-xs text-gray-400 space-y-1">
{counterElement && (
<div className="flex items-center gap-2">
<span className="text-emerald-400"></span>
<span>
Use <span style={{ color: getElementColor(counterElement) }} className="font-medium">{counterElement}</span> spells for super effective damage (+50%)
</span>
</div>
)}
{nextFloorElement && (
<div className="flex items-center gap-2">
<span className="text-blue-400">🔄</span>
<span>
Floor element: <span style={{ color: getElementColor(nextFloorElement) }} className="font-medium">{nextFloorElement}</span>
</span>
</div>
)}
{hasHighArmor && (
<div className="flex items-center gap-2">
<span className="text-red-400">🛡</span>
<span>High armor consider armor-piercing or raw damage spells</span>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-amber-400">💡</span>
<span>Ensure mana pools are full before attempting</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-amber-600/60 border border-amber-400" />
<span>Current</span>
</div>
</div>
);
}
// ─── Guardian Roster ─────────────────────────────────────────────────────────
function GuardianRoster({ clearedFloors }: { clearedFloors: Record<number, boolean> }) {
return (
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="🏛️ Guardian Roster" />
<CardContent className="pt-0">
<div className="space-y-2">
{GUARDIAN_FLOORS.map((floor) => (
<GuardianRosterItem key={floor} floor={floor} guardian={GUARDIANS[floor]} isDefeated={!!clearedFloors[floor]} />
))}
</div>
</CardContent>
</Card>
);
}
function GuardianRosterItem({ floor, guardian, isDefeated }: { floor: number; guardian: (typeof GUARDIANS)[number]; isDefeated: boolean }) {
return (
<div
className={`flex items-center justify-between p-2 rounded border ${
isDefeated
? 'bg-emerald-900/20 border-emerald-800/40'
: 'bg-gray-800/40 border-gray-700/50'
}`}
>
<div className="flex items-center gap-2">
<div
className="w-7 h-7 rounded flex items-center justify-center text-xs font-bold"
style={{
backgroundColor: isDefeated ? `${guardian.color}30` : '#374151',
color: isDefeated ? guardian.color : '#6B7280',
}}
>
{floor}
</div>
<div>
<div className={`text-sm font-medium ${isDefeated ? 'text-gray-100' : 'text-gray-400'}`}>
{guardian.name}
</div>
<div className="flex items-center gap-1.5">
<span
className="text-[10px] px-1.5 py-0.5 rounded"
style={{
backgroundColor: `${guardian.color}15`,
color: guardian.color,
}}
>
{guardian.element}
</span>
<span className="text-[10px] text-gray-500">HP: {fmt(guardian.hp)}</span>
</div>
</div>
</div>
<div>
{isDefeated ? (
<Badge variant="outline" className="border-emerald-600 text-emerald-400 text-xs">
Defeated
</Badge>
) : (
<Badge variant="outline" className="border-gray-600 text-gray-500 text-xs">
Undefeated
</Badge>
)}
</div>
</div>
);
@@ -135,7 +330,6 @@ export function SpireSummaryTab() {
setMounted(true);
}, []);
// Derived data
const defeatedGuardians = useMemo(() => {
return GUARDIAN_FLOORS.filter((floor) => clearedFloors[floor]);
}, [clearedFloors]);
@@ -146,9 +340,6 @@ export function SpireSummaryTab() {
const nextGuardianData = nextGuardian ? GUARDIANS[nextGuardian] : null;
const counterElement = nextGuardianData ? getCounterElement(nextGuardianData.element) : null;
const nextFloorElement = nextGuardian ? FLOOR_ELEM_CYCLE[(nextGuardian - 1) % FLOOR_ELEM_CYCLE.length] : null;
const totalFloorsCleared = useMemo(() => {
return Object.values(clearedFloors).filter(Boolean).length;
}, [clearedFloors]);
@@ -164,31 +355,13 @@ export function SpireSummaryTab() {
return (
<DebugName name="SpireSummaryTab">
<div className="space-y-4">
{/* ── Top Stats Row ─────────────────────────────────────────────── */}
<Card className="bg-gray-900/60 border-gray-700">
<CardContent className="py-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-amber-400">{maxFloorReached}</div>
<div className="text-xs text-gray-400 mt-0.5">Max Floor Reached</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-200">{totalFloorsCleared}</div>
<div className="text-xs text-gray-400 mt-0.5">Floors Cleared</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-emerald-400">{defeatedGuardians.length}</div>
<div className="text-xs text-gray-400 mt-0.5">Guardians Defeated</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-400">{fmt(insight)}</div>
<div className="text-xs text-gray-400 mt-0.5">Insight Earned</div>
</div>
</div>
</CardContent>
</Card>
<TopStatsRow
maxFloorReached={maxFloorReached}
totalFloorsCleared={totalFloorsCleared}
defeatedCount={defeatedGuardians.length}
insight={insight}
/>
{/* ── Climb the Spire Button ────────────────────────────────────── */}
<DebugName name="ClimbSpireButton">
<Button
className="w-full bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-700 hover:to-orange-600 text-white"
@@ -200,146 +373,12 @@ export function SpireSummaryTab() {
</Button>
</DebugName>
{/* ── Next Guardian + Preparation ───────────────────────────────── */}
{nextGuardianData && nextGuardian && (
<Card className="bg-gray-900/60 border-amber-800/40">
<SectionHeader
title={`🛡️ Next Guardian — Floor ${nextGuardian}`}
className="text-amber-400"
/>
<CardContent className="pt-0 space-y-3">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-lg font-bold"
style={{
backgroundColor: `${nextGuardianData.color}20`,
border: `2px solid ${nextGuardianData.color}`,
color: nextGuardianData.color,
}}
>
{nextGuardian}
</div>
<div>
<div className="font-semibold text-gray-100">{nextGuardianData.name}</div>
<div className="flex items-center gap-2 mt-0.5">
<Badge
variant="outline"
className="text-xs"
style={{ borderColor: getElementColor(nextGuardianData.element), color: getElementColor(nextGuardianData.element) }}
>
{nextGuardianData.element}
</Badge>
<span className="text-xs text-gray-500">HP: {fmt(nextGuardianData.hp)}</span>
{nextGuardianData.armor && (
<span className="text-xs text-gray-500">
Armor: {Math.round(nextGuardianData.armor * 100)}%
</span>
)}
</div>
</div>
</div>
{/* Preparation recommendations */}
<div className="bg-gray-800/50 rounded-lg p-3 space-y-2">
<div className="text-xs font-medium text-gray-300">Recommended Preparation:</div>
<div className="text-xs text-gray-400 space-y-1">
{counterElement && (
<div className="flex items-center gap-2">
<span className="text-emerald-400"></span>
<span>
Use <span style={{ color: getElementColor(counterElement) }} className="font-medium">{counterElement}</span> spells for super effective damage (+50%)
</span>
</div>
)}
{nextFloorElement && (
<div className="flex items-center gap-2">
<span className="text-blue-400">🔄</span>
<span>
Floor element: <span style={{ color: getElementColor(nextFloorElement) }} className="font-medium">{nextFloorElement}</span>
</span>
</div>
)}
{nextGuardianData.armor && nextGuardianData.armor > 0.15 && (
<div className="flex items-center gap-2">
<span className="text-red-400">🛡</span>
<span>High armor consider armor-piercing or raw damage spells</span>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-amber-400">💡</span>
<span>Ensure mana pools are full before attempting</span>
</div>
</div>
</div>
</CardContent>
</Card>
<NextGuardianCard nextGuardian={nextGuardian} nextGuardianData={nextGuardianData} />
)}
{/* ── All Guardians List ────────────────────────────────────────── */}
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="🏛️ Guardian Roster" />
<CardContent className="pt-0">
<div className="space-y-2">
{GUARDIAN_FLOORS.map((floor) => {
const guardian = GUARDIANS[floor];
const isDefeated = clearedFloors[floor];
<GuardianRoster clearedFloors={clearedFloors} />
return (
<div
key={floor}
className={`flex items-center justify-between p-2 rounded border ${
isDefeated
? 'bg-emerald-900/20 border-emerald-800/40'
: 'bg-gray-800/40 border-gray-700/50'
}`}
>
<div className="flex items-center gap-2">
<div
className="w-7 h-7 rounded flex items-center justify-center text-xs font-bold"
style={{
backgroundColor: isDefeated ? `${guardian.color}30` : '#374151',
color: isDefeated ? guardian.color : '#6B7280',
}}
>
{floor}
</div>
<div>
<div className={`text-sm font-medium ${isDefeated ? 'text-gray-100' : 'text-gray-400'}`}>
{guardian.name}
</div>
<div className="flex items-center gap-1.5">
<span
className="text-[10px] px-1.5 py-0.5 rounded"
style={{
backgroundColor: `${guardian.color}15`,
color: guardian.color,
}}
>
{guardian.element}
</span>
<span className="text-[10px] text-gray-500">HP: {fmt(guardian.hp)}</span>
</div>
</div>
</div>
<div>
{isDefeated ? (
<Badge variant="outline" className="border-emerald-600 text-emerald-400 text-xs">
Defeated
</Badge>
) : (
<Badge variant="outline" className="border-gray-600 text-gray-500 text-xs">
Undefeated
</Badge>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* ── Floor Progress Map ────────────────────────────────────────── */}
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="🗺️ Floor Progress" />
<CardContent className="pt-0">
@@ -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>
);
}