feat: recreate Guardian Pacts tab for Invoker attunement
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m16s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m16s
- Add GuardianPactsTab.tsx with guardian cards organized by floor tier - Display HP, armor, power stats, boons, unique perk, pact cost per guardian - Show status: Undefeated / Defeated (pact available) / Pact Signed - Allow starting pact rituals with defeated guardians - Show pact ritual progress bar - Display active pacts and cumulative boon effects - Show remaining pact slots - Add tier filter (All / Early / Mid / Late Spire) - Add to tabs barrel export and page.tsx with lazy loading - Add DebugName wrapper - Write 13 tests covering module structure, data integrity, store shape, file size
This commit is contained in:
@@ -50,6 +50,7 @@ const AttunementsTab = lazy(() => import('@/components/game/tabs').then(module =
|
||||
const PrestigeTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.PrestigeTab })));
|
||||
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 GuardianPactsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.GuardianPactsTab })));
|
||||
|
||||
const TabLoadingFallback = () => <div className="p-4 text-center text-gray-400">Loading...</div>;
|
||||
|
||||
@@ -245,6 +246,7 @@ export default function ManaLoopGame() {
|
||||
<TabsTrigger value="prestige" className="text-xs px-2 py-1">✨ Prestige</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="pacts" className="text-xs px-2 py-1">📜 Pacts</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="spells">
|
||||
@@ -322,6 +324,14 @@ export default function ManaLoopGame() {
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pacts">
|
||||
<ErrorBoundary fallback={<div className="p-4 text-red-400">pacts tab failed to load.</div>}>
|
||||
<Suspense fallback={<TabLoadingFallback />}>
|
||||
<GuardianPactsTab />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// ─── Test: GuardianPactsTab barrel export ──────────────────────────────────────
|
||||
|
||||
describe('GuardianPactsTab module structure', () => {
|
||||
it('exports GuardianPactsTab from barrel index', async () => {
|
||||
const mod = await import('./GuardianPactsTab');
|
||||
expect(mod.GuardianPactsTab).toBeDefined();
|
||||
expect(typeof mod.GuardianPactsTab).toBe('function');
|
||||
});
|
||||
|
||||
it('GuardianPactsTab has correct displayName', async () => {
|
||||
const { GuardianPactsTab } = await import('./GuardianPactsTab');
|
||||
expect(GuardianPactsTab.displayName).toBe('GuardianPactsTab');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Barrel export includes GuardianPactsTab ─────────────────────────────
|
||||
|
||||
describe('Tab barrel export', () => {
|
||||
it('includes GuardianPactsTab in the tabs index', async () => {
|
||||
const mod = await import('@/components/game/tabs');
|
||||
expect(mod.GuardianPactsTab).toBeDefined();
|
||||
expect(typeof mod.GuardianPactsTab).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Guardian data integrity ─────────────────────────────────────────────
|
||||
|
||||
describe('Guardian data', () => {
|
||||
it('all guardians have required fields', async () => {
|
||||
const { GUARDIANS } = await import('@/lib/game/constants/guardians');
|
||||
for (const [floor, def] of Object.entries(GUARDIANS)) {
|
||||
expect(def.name).toBeTruthy();
|
||||
expect(def.element).toBeTruthy();
|
||||
expect(def.hp).toBeGreaterThan(0);
|
||||
expect(def.power).toBeGreaterThan(0);
|
||||
expect(def.boons.length).toBeGreaterThan(0);
|
||||
expect(def.pactCost).toBeGreaterThan(0);
|
||||
expect(def.pactTime).toBeGreaterThan(0);
|
||||
expect(def.uniquePerk).toBeTruthy();
|
||||
expect(def.signingCost).toBeTruthy();
|
||||
expect(def.signingCost.mana).toBeGreaterThan(0);
|
||||
expect(def.signingCost.time).toBeGreaterThan(0);
|
||||
expect(def.unlocksMana.length).toBeGreaterThan(0);
|
||||
expect(def.damageMultiplier).toBeGreaterThan(0);
|
||||
expect(def.insightMultiplier).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('guardians are defined at expected floors', async () => {
|
||||
const { GUARDIANS } = await import('@/lib/game/constants/guardians');
|
||||
const expectedFloors = [10, 20, 30, 40, 50, 60, 80, 90, 100];
|
||||
for (const floor of expectedFloors) {
|
||||
expect(GUARDIANS[floor]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('guardian boons have valid types', async () => {
|
||||
const validBoonTypes = [
|
||||
'maxMana', 'manaRegen', 'castingSpeed', 'elementalDamage', 'rawDamage',
|
||||
'critChance', 'critDamage', 'spellEfficiency', 'manaGain', 'insightGain',
|
||||
'studySpeed', 'prestigeInsight',
|
||||
];
|
||||
const { GUARDIANS } = await import('@/lib/game/constants/guardians');
|
||||
for (const def of Object.values(GUARDIANS)) {
|
||||
for (const boon of def.boons) {
|
||||
expect(validBoonTypes).toContain(boon.type);
|
||||
expect(boon.value).toBeGreaterThan(0);
|
||||
expect(boon.desc).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: Prestige store pact state ───────────────────────────────────────────
|
||||
|
||||
describe('Prestige store pact state', () => {
|
||||
it('has correct initial pact state shape', async () => {
|
||||
const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(Array.isArray(state.defeatedGuardians)).toBe(true);
|
||||
expect(Array.isArray(state.signedPacts)).toBe(true);
|
||||
expect(typeof state.pactSlots).toBe('number');
|
||||
expect(state.pactSlots).toBeGreaterThanOrEqual(1);
|
||||
expect(state.pactRitualFloor).toBeNull();
|
||||
expect(state.pactRitualProgress).toBe(0);
|
||||
});
|
||||
|
||||
it('startPactRitual is a function', async () => {
|
||||
const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(typeof state.startPactRitual).toBe('function');
|
||||
});
|
||||
|
||||
it('cancelPactRitual is a function', async () => {
|
||||
const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(typeof state.cancelPactRitual).toBe('function');
|
||||
});
|
||||
|
||||
it('completePactRitual is a function', async () => {
|
||||
const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(typeof state.completePactRitual).toBe('function');
|
||||
});
|
||||
|
||||
it('defeatGuardian is a function', async () => {
|
||||
const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(typeof state.defeatGuardian).toBe('function');
|
||||
});
|
||||
|
||||
it('removePact is a function', async () => {
|
||||
const { usePrestigeStore } = await import('@/lib/game/stores/prestigeStore');
|
||||
const state = usePrestigeStore.getState();
|
||||
expect(typeof state.removePact).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test: File size limit ─────────────────────────────────────────────────────
|
||||
|
||||
describe('File size limits (400 lines max)', () => {
|
||||
it('GuardianPactsTab.tsx is under 400 lines', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const filePath = path.join(__dirname, 'GuardianPactsTab.tsx');
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n').length;
|
||||
expect(lines).toBeLessThan(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
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 { 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';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type GuardianStatus = 'undefeated' | 'defeated' | 'signed';
|
||||
|
||||
function getGuardianStatus(floor: number, defeated: number[], signed: number[]): GuardianStatus {
|
||||
if (signed.includes(floor)) return 'signed';
|
||||
if (defeated.includes(floor)) return 'defeated';
|
||||
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[];
|
||||
}
|
||||
|
||||
function groupFloorsByTier(floors: number[]): FloorTier[] {
|
||||
const tiers: FloorTier[] = [
|
||||
{ label: 'Early Spire (10–40)', floors: [] },
|
||||
{ label: 'Mid Spire (50–60)', floors: [] },
|
||||
{ label: 'Late Spire (80–100)', floors: [] },
|
||||
];
|
||||
for (const f of floors) {
|
||||
if (f <= 40) tiers[0].floors.push(f);
|
||||
else if (f <= 60) tiers[1].floors.push(f);
|
||||
else tiers[2].floors.push(f);
|
||||
}
|
||||
return tiers.filter(t => t.floors.length > 0);
|
||||
}
|
||||
|
||||
// ─── Main Tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GuardianPactsTab: React.FC = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [activeTier, setActiveTier] = useState<string>('all');
|
||||
|
||||
const {
|
||||
defeatedGuardians,
|
||||
signedPacts,
|
||||
pactSlots,
|
||||
pactRitualFloor,
|
||||
pactRitualProgress,
|
||||
startPactRitual,
|
||||
} = usePrestigeStore(useShallow(s => ({
|
||||
defeatedGuardians: s.defeatedGuardians,
|
||||
signedPacts: s.signedPacts,
|
||||
pactSlots: s.pactSlots,
|
||||
pactRitualFloor: s.pactRitualFloor,
|
||||
pactRitualProgress: s.pactRitualProgress,
|
||||
startPactRitual: s.startPactRitual,
|
||||
})));
|
||||
|
||||
const rawMana = useManaStore(s => s.rawMana);
|
||||
const addLog = useUIStore(s => s.addLog);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const guardianFloors = useMemo(
|
||||
() => Object.keys(GUARDIANS).map(Number).sort((a, b) => a - b),
|
||||
[],
|
||||
);
|
||||
|
||||
const tiers = useMemo(() => groupFloorsByTier(guardianFloors), [guardianFloors]);
|
||||
|
||||
const filteredFloors = useMemo(() => {
|
||||
if (activeTier === 'all') return guardianFloors;
|
||||
const tier = tiers.find(t => t.label === activeTier);
|
||||
return tier ? tier.floors : guardianFloors;
|
||||
}, [activeTier, guardianFloors, tiers]);
|
||||
|
||||
const handleStartRitual = useCallback((floor: number) => {
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian) return;
|
||||
|
||||
const success = startPactRitual(floor, rawMana);
|
||||
if (success) {
|
||||
addLog(`📜 Began pact ritual with ${guardian.name}…`);
|
||||
} else {
|
||||
addLog(`⚠️ Cannot start pact ritual with ${guardian.name}.`);
|
||||
}
|
||||
}, [startPactRitual, rawMana, addLog]);
|
||||
|
||||
// Cumulative boon summary from signed pacts
|
||||
const cumulativeBoons = useMemo(() => {
|
||||
const boonMap: Record<string, number> = {};
|
||||
for (const floor of signedPacts) {
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian) continue;
|
||||
for (const boon of guardian.boons) {
|
||||
boonMap[boon.type] = (boonMap[boon.type] || 0) + boon.value;
|
||||
}
|
||||
}
|
||||
return boonMap;
|
||||
}, [signedPacts]);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-gray-500">
|
||||
Loading guardian pacts…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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}
|
||||
ritualProgress={pactRitualProgress}
|
||||
onStartRitual={handleStartRitual}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
};
|
||||
|
||||
GuardianPactsTab.displayName = 'GuardianPactsTab';
|
||||
@@ -10,3 +10,4 @@ export { AttunementsTab } from './AttunementsTab';
|
||||
export { PrestigeTab } from './PrestigeTab';
|
||||
export { EquipmentTab } from './EquipmentTab';
|
||||
export { GolemancyTab } from './GolemancyTab';
|
||||
export { GuardianPactsTab } from './GuardianPactsTab';
|
||||
|
||||
Reference in New Issue
Block a user