feat: add Spire Summary Tab showing guardian progress, floor map, and climb button
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
# Circular Dependencies
|
# Circular Dependencies
|
||||||
Generated: 2026-05-19T20:26:04.052Z
|
Generated: 2026-05-19T20:37:58.097Z
|
||||||
Found: 3 circular chain(s) — these MUST be fixed before modifying involved files.
|
Found: 3 circular chain(s) — these MUST be fixed before modifying involved files.
|
||||||
|
|
||||||
1. Processed 121 files (1.3s) (4 warnings)
|
1. Processed 121 files (1.2s) (4 warnings)
|
||||||
2. 1) data/equipment/index.ts > data/equipment/utils.ts
|
2. 1) data/equipment/index.ts > data/equipment/utils.ts
|
||||||
3. 2) data/golems/index.ts > data/golems/utils.ts
|
3. 2) data/golems/index.ts > data/golems/utils.ts
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"generated": "2026-05-19T20:26:02.602Z",
|
"generated": "2026-05-19T20:37:56.730Z",
|
||||||
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
|
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
|
||||||
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ Mana-Loop/
|
|||||||
│ │ │ │ ├── PrestigeTab.test.ts
|
│ │ │ │ ├── PrestigeTab.test.ts
|
||||||
│ │ │ │ ├── PrestigeTab.tsx
|
│ │ │ │ ├── PrestigeTab.tsx
|
||||||
│ │ │ │ ├── SpellsTab.tsx
|
│ │ │ │ ├── SpellsTab.tsx
|
||||||
|
│ │ │ │ ├── SpireSummaryTab.test.ts
|
||||||
|
│ │ │ │ ├── SpireSummaryTab.tsx
|
||||||
│ │ │ │ ├── StatsTab.tsx
|
│ │ │ │ ├── StatsTab.tsx
|
||||||
│ │ │ │ └── index.ts
|
│ │ │ │ └── index.ts
|
||||||
│ │ │ ├── ActionButtons.tsx
|
│ │ │ ├── ActionButtons.tsx
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const PrestigeTab = lazy(() => import('@/components/game/tabs').then(module => (
|
|||||||
const EquipmentTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.EquipmentTab })));
|
const EquipmentTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.EquipmentTab })));
|
||||||
const GolemancyTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.GolemancyTab })));
|
const GolemancyTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.GolemancyTab })));
|
||||||
const GuardianPactsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.GuardianPactsTab })));
|
const GuardianPactsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.GuardianPactsTab })));
|
||||||
|
const SpireSummaryTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.SpireSummaryTab })));
|
||||||
|
|
||||||
const TabLoadingFallback = () => <div className="p-4 text-center text-gray-400">Loading...</div>;
|
const TabLoadingFallback = () => <div className="p-4 text-center text-gray-400">Loading...</div>;
|
||||||
|
|
||||||
@@ -247,6 +248,7 @@ export default function ManaLoopGame() {
|
|||||||
<TabsTrigger value="equipment" className="text-xs px-2 py-1">⚔️ Equipment</TabsTrigger>
|
<TabsTrigger value="equipment" className="text-xs px-2 py-1">⚔️ Equipment</TabsTrigger>
|
||||||
<TabsTrigger value="golemancy" className="text-xs px-2 py-1">🗿 Golemancy</TabsTrigger>
|
<TabsTrigger value="golemancy" className="text-xs px-2 py-1">🗿 Golemancy</TabsTrigger>
|
||||||
<TabsTrigger value="pacts" className="text-xs px-2 py-1">📜 Pacts</TabsTrigger>
|
<TabsTrigger value="pacts" className="text-xs px-2 py-1">📜 Pacts</TabsTrigger>
|
||||||
|
<TabsTrigger value="spire" className="text-xs px-2 py-1">🏔️ Spire</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="spells">
|
<TabsContent value="spells">
|
||||||
@@ -332,6 +334,14 @@ export default function ManaLoopGame() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="spire">
|
||||||
|
<ErrorBoundary fallback={<div className="p-4 text-red-400">spire tab failed to load.</div>}>
|
||||||
|
<Suspense fallback={<TabLoadingFallback />}>
|
||||||
|
<SpireSummaryTab />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
// ─── Test: SpireSummaryTab barrel export ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('SpireSummaryTab module structure', () => {
|
||||||
|
it('exports SpireSummaryTab from its module', async () => {
|
||||||
|
const mod = await import('./SpireSummaryTab');
|
||||||
|
expect(mod.SpireSummaryTab).toBeDefined();
|
||||||
|
expect(typeof mod.SpireSummaryTab).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SpireSummaryTab has correct displayName', async () => {
|
||||||
|
const { SpireSummaryTab } = await import('./SpireSummaryTab');
|
||||||
|
expect(SpireSummaryTab.displayName).toBe('SpireSummaryTab');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Barrel export includes SpireSummaryTab ──────────────────────────────
|
||||||
|
|
||||||
|
describe('Tab barrel export', () => {
|
||||||
|
it('includes SpireSummaryTab in the tabs index', async () => {
|
||||||
|
const mod = await import('@/components/game/tabs');
|
||||||
|
expect(mod.SpireSummaryTab).toBeDefined();
|
||||||
|
expect(typeof mod.SpireSummaryTab).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Guardian data ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Guardian constants', () => {
|
||||||
|
it('has 9 guardians defined', async () => {
|
||||||
|
const { GUARDIANS } = await import('@/lib/game/constants');
|
||||||
|
expect(Object.keys(GUARDIANS).length).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('guardians are at expected floors', async () => {
|
||||||
|
const { GUARDIANS } = await import('@/lib/game/constants');
|
||||||
|
const expectedFloors = [10, 20, 30, 40, 50, 60, 80, 90, 100];
|
||||||
|
for (const floor of expectedFloors) {
|
||||||
|
expect(GUARDIANS[floor]).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all guardians have required fields', async () => {
|
||||||
|
const { GUARDIANS } = await import('@/lib/game/constants');
|
||||||
|
for (const [, def] of Object.entries(GUARDIANS)) {
|
||||||
|
expect(def.name).toBeTruthy();
|
||||||
|
expect(def.element).toBeTruthy();
|
||||||
|
expect(def.hp).toBeGreaterThan(0);
|
||||||
|
expect(def.color).toBeTruthy();
|
||||||
|
expect(def.boons).toBeInstanceOf(Array);
|
||||||
|
expect(def.boons.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all guardians have unique elements', async () => {
|
||||||
|
const { GUARDIANS } = await import('@/lib/game/constants');
|
||||||
|
const elements = Object.values(GUARDIANS).map((g) => g.element);
|
||||||
|
const uniqueElements = new Set(elements);
|
||||||
|
expect(uniqueElements.size).toBe(elements.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Combat store spire fields ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Combat store spire state', () => {
|
||||||
|
it('useCombatStore is importable', async () => {
|
||||||
|
const mod = await import('@/lib/game/stores');
|
||||||
|
expect(mod.useCombatStore).toBeDefined();
|
||||||
|
expect(typeof mod.useCombatStore).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Floor element cycle ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Floor element cycle', () => {
|
||||||
|
it('FLOOR_ELEM_CYCLE has 7 elements', async () => {
|
||||||
|
const { FLOOR_ELEM_CYCLE } = await import('@/lib/game/constants');
|
||||||
|
expect(FLOOR_ELEM_CYCLE.length).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('element opposites define expected pairs', async () => {
|
||||||
|
const { ELEMENT_OPPOSITES } = await import('@/lib/game/constants');
|
||||||
|
// Core pairs are symmetric
|
||||||
|
expect(ELEMENT_OPPOSITES['fire']).toBe('water');
|
||||||
|
expect(ELEMENT_OPPOSITES['water']).toBe('fire');
|
||||||
|
expect(ELEMENT_OPPOSITES['air']).toBe('earth');
|
||||||
|
expect(ELEMENT_OPPOSITES['earth']).toBe('air');
|
||||||
|
expect(ELEMENT_OPPOSITES['light']).toBe('dark');
|
||||||
|
expect(ELEMENT_OPPOSITES['dark']).toBe('light');
|
||||||
|
// Lightning has an asymmetric opposite (grounding)
|
||||||
|
expect(ELEMENT_OPPOSITES['lightning']).toBe('earth');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: File size limit ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('File size limits (400 lines max)', () => {
|
||||||
|
it('SpireSummaryTab.tsx is under 400 lines', async () => {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const path = await import('path');
|
||||||
|
const filePath = path.join(__dirname, 'SpireSummaryTab.tsx');
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const lines = content.split('\n').length;
|
||||||
|
expect(lines).toBeLessThan(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { useCombatStore, usePrestigeStore, fmt } from '@/lib/game/stores';
|
||||||
|
import { GUARDIANS, ELEMENT_OPPOSITES, FLOOR_ELEM_CYCLE } from '@/lib/game/constants';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { SectionHeader } from '@/components/ui/section-header';
|
||||||
|
import { DebugName } from '@/components/game/debug/debug-context';
|
||||||
|
import { Mountain } from 'lucide-react';
|
||||||
|
|
||||||
|
// ─── Guardian Data ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const GUARDIAN_FLOORS = Object.keys(GUARDIANS)
|
||||||
|
.map(Number)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
// ─── Helper: Get Counter Element ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getCounterElement(element: string): string | null {
|
||||||
|
return ELEMENT_OPPOSITES[element] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getElementColor(element: string): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
fire: '#FF6B35',
|
||||||
|
water: '#4ECDC4',
|
||||||
|
air: '#00D4FF',
|
||||||
|
earth: '#F4A261',
|
||||||
|
light: '#FFD700',
|
||||||
|
dark: '#9B59B6',
|
||||||
|
death: '#778CA3',
|
||||||
|
void: '#4A235A',
|
||||||
|
stellar: '#F0E68C',
|
||||||
|
};
|
||||||
|
return colors[element] || '#9CA3AF';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sub-component: Floor Progress Bar ────────────────────────────────────────
|
||||||
|
|
||||||
|
function FloorProgressBar({ maxFloor, clearedFloors }: { maxFloor: number; clearedFloors: Record<number, boolean> }) {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{rows.reverse().map((row) => (
|
||||||
|
<div key={row[0]} className="flex gap-1">
|
||||||
|
{row.map((floor) => {
|
||||||
|
const isCleared = clearedSet.has(floor);
|
||||||
|
const isGuardian = !!GUARDIANS[floor];
|
||||||
|
const isCurrent = floor === maxFloor;
|
||||||
|
|
||||||
|
let bgClass = 'bg-gray-800';
|
||||||
|
if (isCleared) bgClass = 'bg-emerald-600/60';
|
||||||
|
else if (isCurrent) bgClass = 'bg-amber-600/60';
|
||||||
|
|
||||||
|
const borderClass = isGuardian
|
||||||
|
? 'border-amber-500'
|
||||||
|
: isCurrent
|
||||||
|
? 'border-amber-400'
|
||||||
|
: 'border-gray-700';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={floor}
|
||||||
|
className={`w-7 h-7 flex items-center justify-center text-[9px] rounded border ${bgClass} ${borderClass} ${
|
||||||
|
isGuardian ? 'font-bold' : ''
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
GUARDIANS[floor]
|
||||||
|
? `Floor ${floor} — ${GUARDIANS[floor].name} (${GUARDIANS[floor].element})`
|
||||||
|
: `Floor ${floor}${isCleared ? ' (cleared)' : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{floor}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function SpireSummaryTab() {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentFloor,
|
||||||
|
maxFloorReached,
|
||||||
|
clearedFloors,
|
||||||
|
enterSpireMode,
|
||||||
|
} = useCombatStore(useShallow((s) => ({
|
||||||
|
currentFloor: s.currentFloor,
|
||||||
|
maxFloorReached: s.maxFloorReached,
|
||||||
|
clearedFloors: s.clearedFloors,
|
||||||
|
enterSpireMode: s.enterSpireMode,
|
||||||
|
})));
|
||||||
|
|
||||||
|
const { insight } = usePrestigeStore(useShallow((s) => ({
|
||||||
|
insight: s.insight,
|
||||||
|
})));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Derived data
|
||||||
|
const defeatedGuardians = useMemo(() => {
|
||||||
|
return GUARDIAN_FLOORS.filter((floor) => clearedFloors[floor]);
|
||||||
|
}, [clearedFloors]);
|
||||||
|
|
||||||
|
const nextGuardian = useMemo(() => {
|
||||||
|
return GUARDIAN_FLOORS.find((floor) => !clearedFloors[floor]) || null;
|
||||||
|
}, [clearedFloors]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8 text-gray-500">
|
||||||
|
Loading spire data…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* ── 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"
|
||||||
|
size="lg"
|
||||||
|
onClick={enterSpireMode}
|
||||||
|
>
|
||||||
|
<Mountain className="w-5 h-5 mr-2" />
|
||||||
|
Climb the Spire
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 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];
|
||||||
|
|
||||||
|
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">
|
||||||
|
<ScrollArea className="h-[300px] pr-2">
|
||||||
|
<FloorProgressBar maxFloor={maxFloorReached} clearedFloors={clearedFloors} />
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</DebugName>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SpireSummaryTab.displayName = 'SpireSummaryTab';
|
||||||
@@ -11,3 +11,4 @@ export { PrestigeTab } from './PrestigeTab';
|
|||||||
export { EquipmentTab } from './EquipmentTab';
|
export { EquipmentTab } from './EquipmentTab';
|
||||||
export { GolemancyTab } from './GolemancyTab';
|
export { GolemancyTab } from './GolemancyTab';
|
||||||
export { GuardianPactsTab } from './GuardianPactsTab';
|
export { GuardianPactsTab } from './GuardianPactsTab';
|
||||||
|
export { SpireSummaryTab } from './SpireSummaryTab';
|
||||||
|
|||||||
Reference in New Issue
Block a user