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:
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user