From 1cda85929d0ea9e7bd01a0a548a301c4a8074b85 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Tue, 19 May 2026 22:37:53 +0200 Subject: [PATCH] feat: recreate Guardian Pacts tab for Invoker attunement - 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 --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 2 +- docs/project-structure.txt | 2 + src/app/page.tsx | 10 + .../game/tabs/GuardianPactsTab.test.ts | 132 ++++++ src/components/game/tabs/GuardianPactsTab.tsx | 391 ++++++++++++++++++ src/components/game/tabs/index.ts | 1 + 7 files changed, 538 insertions(+), 2 deletions(-) create mode 100644 src/components/game/tabs/GuardianPactsTab.test.ts create mode 100644 src/components/game/tabs/GuardianPactsTab.tsx diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index e435034..3a859c3 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,5 +1,5 @@ # Circular Dependencies -Generated: 2026-05-19T20:04:31.355Z +Generated: 2026-05-19T20:26:04.052Z Found: 3 circular chain(s) — these MUST be fixed before modifying involved files. 1. Processed 121 files (1.3s) (4 warnings) diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 4f54073..e79dbc5 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-05-19T20:04:29.897Z", + "generated": "2026-05-19T20:26:02.602Z", "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." }, diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 0c1ba03..43119e6 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -119,6 +119,8 @@ Mana-Loop/ │ │ │ │ ├── EquipmentTab.tsx │ │ │ │ ├── GolemancyTab.test.ts │ │ │ │ ├── GolemancyTab.tsx +│ │ │ │ ├── GuardianPactsTab.test.ts +│ │ │ │ ├── GuardianPactsTab.tsx │ │ │ │ ├── PrestigeTab.test.ts │ │ │ │ ├── PrestigeTab.tsx │ │ │ │ ├── SpellsTab.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index d5a53bd..4f017e2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 = () =>
Loading...
; @@ -245,6 +246,7 @@ export default function ManaLoopGame() { ✨ Prestige ⚔️ Equipment 🗿 Golemancy + 📜 Pacts @@ -322,6 +324,14 @@ export default function ManaLoopGame() { + + + pacts tab failed to load.}> + }> + + + + diff --git a/src/components/game/tabs/GuardianPactsTab.test.ts b/src/components/game/tabs/GuardianPactsTab.test.ts new file mode 100644 index 0000000..1f38076 --- /dev/null +++ b/src/components/game/tabs/GuardianPactsTab.test.ts @@ -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); + }); +}); diff --git a/src/components/game/tabs/GuardianPactsTab.tsx b/src/components/game/tabs/GuardianPactsTab.tsx new file mode 100644 index 0000000..b0b7e11 --- /dev/null +++ b/src/components/game/tabs/GuardianPactsTab.tsx @@ -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 = 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 = { + 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 ( + + +
+
+ + {elemSym} + {guardian.name} + +
Floor {floor} · {elemDef?.name ?? guardian.element}
+
+ + {sc.label} + +
+
+ + + {/* Stats */} +
+
+ + HP: {guardian.hp.toLocaleString()} +
+
+ + PWR: {guardian.power.toLocaleString()} +
+
+ + ARM: {Math.round((guardian.armor ?? 0) * 100)}% +
+
+ + {/* Boons */} +
+
+ Boons +
+
+ {guardian.boons.map((boon: GuardianBoon, i: number) => ( + + {boon.desc} + + ))} +
+
+ + {/* Unique Perk */} +
+ Perk: {guardian.uniquePerk} +
+ + {/* Pact Cost */} +
+
+ + {formatHours(guardian.pactTime)} +
+
+ Cost: {guardian.pactCost.toLocaleString()} mana +
+
+ + {/* Ritual Progress */} + {isRitualActive && ( +
+
+ Ritual in progress… + {ritualProgress}/{ritualTime}h +
+
+
+
+ {ritualComplete && ( +
+ Ritual complete — pact will be signed on next tick +
+ )} +
+ )} + + {/* Action Button */} + {status === 'defeated' && !isRitualActive && ( + + )} + + + ); +}); + +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('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 = {}; + 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 ( +
+ Loading guardian pacts… +
+ ); + } + + return ( + +
+ {/* Header Summary */} + + +
+
+ + Pact Slots: + {signedPacts.length} / {pactSlots} +
+
+ + Signed: + {signedPacts.length} +
+
+ + Defeated: + {defeatedGuardians.length} +
+
+ + {/* Cumulative Boons */} + {signedPacts.length > 0 && ( +
+
Active Boon Effects:
+
+ {Object.entries(cumulativeBoons).map(([type, value]) => ( + + {type}: +{value} + + ))} +
+
+ )} +
+
+ + {/* Tier Filter */} +
+ + {tiers.map((tier) => ( + + ))} +
+ + {/* Guardian Cards */} + +
+ {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 ( + + ); + })} +
+
+
+
+ ); +}; + +GuardianPactsTab.displayName = 'GuardianPactsTab'; diff --git a/src/components/game/tabs/index.ts b/src/components/game/tabs/index.ts index c788694..e870f5b 100644 --- a/src/components/game/tabs/index.ts +++ b/src/components/game/tabs/index.ts @@ -10,3 +10,4 @@ export { AttunementsTab } from './AttunementsTab'; export { PrestigeTab } from './PrestigeTab'; export { EquipmentTab } from './EquipmentTab'; export { GolemancyTab } from './GolemancyTab'; +export { GuardianPactsTab } from './GuardianPactsTab';