From 0b6ee15e9b670884d2da4efee7a4886dcf7aac26 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Tue, 19 May 2026 22:25:59 +0200 Subject: [PATCH] feat: recreate Golemancy tab with golem loadout configuration --- docs/circular-deps.txt | 4 +- docs/dependency-graph.json | 2 +- docs/project-structure.txt | 2 + src/app/page.tsx | 10 + src/components/game/tabs/GolemancyTab.test.ts | 148 ++++++++ src/components/game/tabs/GolemancyTab.tsx | 339 ++++++++++++++++++ src/components/game/tabs/index.ts | 1 + 7 files changed, 503 insertions(+), 3 deletions(-) create mode 100644 src/components/game/tabs/GolemancyTab.test.ts create mode 100644 src/components/game/tabs/GolemancyTab.tsx diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 1242411..e435034 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,8 +1,8 @@ # Circular Dependencies -Generated: 2026-05-19T18:19:35.896Z +Generated: 2026-05-19T20:04:31.355Z Found: 3 circular chain(s) — these MUST be fixed before modifying involved files. -1. Processed 121 files (1.2s) (4 warnings) +1. Processed 121 files (1.3s) (4 warnings) 2. 1) data/equipment/index.ts > data/equipment/utils.ts 3. 2) data/golems/index.ts > data/golems/utils.ts diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index dd23d7e..4f54073 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-05-19T18:19:34.562Z", + "generated": "2026-05-19T20:04:29.897Z", "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 11b38db..0c1ba03 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -117,6 +117,8 @@ Mana-Loop/ │ │ │ │ ├── DisciplinesTab.tsx │ │ │ │ ├── EquipmentTab.test.ts │ │ │ │ ├── EquipmentTab.tsx +│ │ │ │ ├── GolemancyTab.test.ts +│ │ │ │ ├── GolemancyTab.tsx │ │ │ │ ├── PrestigeTab.test.ts │ │ │ │ ├── PrestigeTab.tsx │ │ │ │ ├── SpellsTab.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index 28f132a..d5a53bd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -49,6 +49,7 @@ const AchievementsTab = lazy(() => import('@/components/game/tabs').then(module const AttunementsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.AttunementsTab }))); 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 TabLoadingFallback = () =>
Loading...
; @@ -243,6 +244,7 @@ export default function ManaLoopGame() { 🏆 Achievements ✨ Prestige ⚔️ Equipment + 🗿 Golemancy @@ -312,6 +314,14 @@ export default function ManaLoopGame() { + + + golemancy tab failed to load.}> + }> + + + + diff --git a/src/components/game/tabs/GolemancyTab.test.ts b/src/components/game/tabs/GolemancyTab.test.ts new file mode 100644 index 0000000..aa13157 --- /dev/null +++ b/src/components/game/tabs/GolemancyTab.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; + +// ─── Test: GolemancyTab barrel export ───────────────────────────────────────── + +describe('GolemancyTab module structure', () => { + it('exports GolemancyTab from barrel index', async () => { + const mod = await import('./GolemancyTab'); + expect(mod.GolemancyTab).toBeDefined(); + expect(typeof mod.GolemancyTab).toBe('function'); + }); + + it('GolemancyTab has correct displayName', async () => { + const { GolemancyTab } = await import('./GolemancyTab'); + expect(GolemancyTab.displayName).toBe('GolemancyTab'); + }); +}); + +// ─── Test: Barrel export includes GolemancyTab ──────────────────────────────── + +describe('Tab barrel export', () => { + it('includes GolemancyTab in the tabs index', async () => { + const mod = await import('@/components/game/tabs'); + expect(mod.GolemancyTab).toBeDefined(); + expect(typeof mod.GolemancyTab).toBe('function'); + }); +}); + +// ─── Test: Golem data integrity ─────────────────────────────────────────────── + +describe('Golem data', () => { + it('all golems have required fields', async () => { + const { GOLEMS_DEF } = await import('@/lib/game/data/golems'); + for (const [id, def] of Object.entries(GOLEMS_DEF)) { + expect(def.id).toBe(id); + expect(def.name).toBeTruthy(); + expect(def.description).toBeTruthy(); + expect(def.baseManaType).toBeTruthy(); + expect(def.summonCost.length).toBeGreaterThan(0); + expect(def.maintenanceCost.length).toBeGreaterThan(0); + expect(def.damage).toBeGreaterThan(0); + expect(def.attackSpeed).toBeGreaterThan(0); + expect(def.hp).toBeGreaterThan(0); + expect(def.armorPierce).toBeGreaterThanOrEqual(0); + expect(def.tier).toBeGreaterThanOrEqual(1); + expect(def.unlockCondition).toBeTruthy(); + } + }); + + it('has golems across multiple tiers', async () => { + const { GOLEMS_DEF } = await import('@/lib/game/data/golems'); + const tiers = new Set(Object.values(GOLEMS_DEF).map(g => g.tier)); + expect(tiers.size).toBeGreaterThanOrEqual(3); + }); + + it('earthGolem is the only base tier golem', async () => { + const { GOLEMS_DEF } = await import('@/lib/game/data/golems'); + const baseGolems = Object.values(GOLEMS_DEF).filter(g => g.tier === 1); + expect(baseGolems.length).toBe(1); + expect(baseGolems[0].id).toBe('earthGolem'); + }); + + it('voidstoneGolem is the highest tier', async () => { + const { GOLEMS_DEF } = await import('@/lib/game/data/golems'); + const voidstone = GOLEMS_DEF.voidstoneGolem; + expect(voidstone).toBeDefined(); + expect(voidstone.tier).toBe(4); + }); +}); + +// ─── Test: Golem utility functions ──────────────────────────────────────────── + +describe('Golem utility functions', () => { + it('getGolemSlots returns 0 for fabricator level < 2', async () => { + const { getGolemSlots } = await import('@/lib/game/data/golems'); + expect(getGolemSlots(0)).toBe(0); + expect(getGolemSlots(1)).toBe(0); + }); + + it('getGolemSlots scales with fabricator level', async () => { + const { getGolemSlots } = await import('@/lib/game/data/golems'); + expect(getGolemSlots(2)).toBe(1); + expect(getGolemSlots(4)).toBe(2); + expect(getGolemSlots(10)).toBe(5); + }); + + it('isGolemUnlocked returns false for unknown golem', async () => { + const { isGolemUnlocked } = await import('@/lib/game/data/golems'); + expect(isGolemUnlocked('nonexistent', {}, [])).toBe(false); + }); + + it('isGolemUnlocked checks attunement level', async () => { + const { isGolemUnlocked } = await import('@/lib/game/data/golems'); + expect(isGolemUnlocked('earthGolem', { fabricator: { active: true, level: 1 } }, [])).toBe(false); + expect(isGolemUnlocked('earthGolem', { fabricator: { active: true, level: 2 } }, [])).toBe(true); + }); + + it('isGolemUnlocked checks mana unlocked', async () => { + const { isGolemUnlocked } = await import('@/lib/game/data/golems'); + expect(isGolemUnlocked('steelGolem', {}, [])).toBe(false); + expect(isGolemUnlocked('steelGolem', {}, ['metal'])).toBe(true); + }); + + it('canAffordGolemSummon returns false for unknown golem', async () => { + const { canAffordGolemSummon } = await import('@/lib/game/data/golems'); + expect(canAffordGolemSummon('nonexistent', 100, {})).toBe(false); + }); + + it('canAffordGolemSummon checks raw mana cost', async () => { + const { canAffordGolemSummon } = await import('@/lib/game/data/golems'); + // earthGolem costs 10 earth + const elements = { earth: { current: 5, max: 100, unlocked: true } }; + expect(canAffordGolemSummon('earthGolem', 0, elements)).toBe(false); + elements.earth.current = 10; + expect(canAffordGolemSummon('earthGolem', 0, elements)).toBe(true); + }); +}); + +// ─── Test: Combat store golemancy state ─────────────────────────────────────── + +describe('Combat store golemancy', () => { + it('toggleGolem is a function', async () => { + const { useCombatStore } = await import('@/lib/game/stores/combatStore'); + const state = useCombatStore.getState(); + expect(typeof state.toggleGolem).toBe('function'); + }); + + it('golemancy state has correct shape', async () => { + const { useCombatStore } = await import('@/lib/game/stores/combatStore'); + const state = useCombatStore.getState(); + expect(state.golemancy).toBeDefined(); + expect(Array.isArray(state.golemancy.enabledGolems)).toBe(true); + expect(Array.isArray(state.golemancy.summonedGolems)).toBe(true); + expect(typeof state.golemancy.lastSummonFloor).toBe('number'); + }); +}); + +// ─── Test: File size limit ──────────────────────────────────────────────────── + +describe('File size limits (400 lines max)', () => { + it('GolemancyTab.tsx is under 400 lines', async () => { + const fs = await import('fs'); + const path = await import('path'); + const filePath = path.join(__dirname, 'GolemancyTab.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/GolemancyTab.tsx b/src/components/game/tabs/GolemancyTab.tsx new file mode 100644 index 0000000..38f9fcc --- /dev/null +++ b/src/components/game/tabs/GolemancyTab.tsx @@ -0,0 +1,339 @@ +'use client'; + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { useCombatStore } from '@/lib/game/stores/combatStore'; +import { useAttunementStore } from '@/lib/game/stores/attunementStore'; +import { useManaStore } from '@/lib/game/stores/manaStore'; +import { GOLEMS_DEF, isGolemUnlocked, canAffordGolemSummon, getGolemSlots } from '@/lib/game/data/golems'; +import type { GolemDef } from '@/lib/game/data/golems'; +import { ELEMENTS } from '@/lib/game/constants/elements'; +import { DebugName } from '@/components/game/debug/debug-context'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import clsx from 'clsx'; + +// ─── Tier configuration ────────────────────────────────────────────────────── + +interface TierConfig { + key: string; + label: string; + tier: number; +} + +const TIERS: TierConfig[] = [ + { key: 'base', label: 'Base', tier: 1 }, + { key: 'elemental', label: 'Elemental', tier: 2 }, + { key: 'hybrid', label: 'Hybrid', tier: 3 }, +]; + +function getTierLabel(tier: number): string { + if (tier <= 1) return 'Base'; + if (tier <= 2) return 'Elemental'; + return 'Hybrid'; +} + +function getTierColor(tier: number): string { + if (tier <= 1) return 'bg-gray-600'; + if (tier <= 2) return 'bg-blue-600'; + if (tier <= 3) return 'bg-purple-600'; + return 'bg-amber-500'; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function formatCost(cost: GolemDef['summonCost'][number]): string { + if (cost.type === 'raw') return `${cost.amount} raw`; + const elem = cost.element ? ELEMENTS[cost.element] : null; + return `${cost.amount} ${elem?.sym ?? ''} ${cost.element ?? ''}`.trim(); +} + +function formatUnlockCondition(golem: GolemDef): string { + const cond = golem.unlockCondition; + switch (cond.type) { + case 'attunement_level': + return `${cond.attunement} level ${cond.level}`; + case 'mana_unlocked': { + const elem = cond.manaType ? ELEMENTS[cond.manaType] : null; + return `Unlock ${elem?.sym ?? ''} ${cond.manaType ?? ''}`.trim(); + } + case 'dual_attunement': + return `${cond.attunements?.join(' + ')} level ${cond.levels?.join('/')}`; + default: + return 'Unknown'; + } +} + +// ─── Golem Card ────────────────────────────────────────────────────────────── + +interface GolemCardProps { + golem: GolemDef; + unlocked: boolean; + enabled: boolean; + summoned: boolean; + canAfford: boolean; + onToggle: (id: string) => void; +} + +const GolemCard: React.FC = React.memo(({ + golem, + unlocked, + enabled, + summoned, + canAfford, + onToggle, +}) => { + const elemColor = ELEMENTS[golem.baseManaType]?.color ?? '#888'; + const elemSym = ELEMENTS[golem.baseManaType]?.sym ?? ''; + + return ( +
+ {/* Header */} +
+
+

{golem.name}

+

{golem.description}

+
+
+ + {elemSym} + + + T{golem.tier} + +
+
+ + {/* Stats grid */} +
+
+ DMG: {golem.damage} +
+
+ SPD: {golem.attackSpeed}/h +
+
+ HP: {golem.hp} +
+
+ AP: {Math.round(golem.armorPierce * 100)}% +
+ {golem.isAoe && ( +
+ AoE: {golem.aoeTargets} targets +
+ )} +
+ + {/* Costs */} +
+
+ Summon:{' '} + {golem.summonCost.map((c, i) => ( + {formatCost(c)}{i < golem.summonCost.length - 1 ? ' + ' : ''} + ))} +
+
+ Upkeep:{' '} + {golem.maintenanceCost.map((c, i) => ( + {formatCost(c)}{i < golem.maintenanceCost.length - 1 ? ' + ' : ''} + ))}/tick +
+
+ + {/* Unlock requirement */} + {!unlocked && ( +
+ 🔒 Requires: {formatUnlockCondition(golem)} +
+ )} + + {/* Status + toggle */} +
+
+ {summoned ? ( + ● Summoned + ) : enabled ? ( + ○ Queued + ) : ( + — Idle + )} +
+ +
+
+ ); +}); + +GolemCard.displayName = 'GolemCard'; + +// ─── Main Tab ──────────────────────────────────────────────────────────────── + +export const GolemancyTab: React.FC = () => { + const [mounted, setMounted] = useState(false); + const [activeTier, setActiveTier] = useState('base'); + + const { golemancy, toggleGolem } = useCombatStore(useShallow(s => ({ + golemancy: s.golemancy, + toggleGolem: s.toggleGolem, + }))); + const attunements = useAttunementStore(s => s.attunements); + const { rawMana, elements } = useManaStore(useShallow(s => ({ + rawMana: s.rawMana, + elements: s.elements, + }))); + + useEffect(() => { + setMounted(true); + }, []); + + // Build attunement lookup for isGolemUnlocked + const attunementLookup = useMemo(() => { + const lookup: Record = {}; + for (const [id, att] of Object.entries(attunements)) { + lookup[id] = { active: att.active, level: att.level }; + } + return lookup; + }, [attunements]); + + const unlockedElements = useMemo( + () => Object.entries(elements).filter(([, e]) => e.unlocked).map(([k]) => k), + [elements], + ); + + // Group golems by tier + const golemsByTier = useMemo(() => { + const groups: Record = { base: [], elemental: [], hybrid: [] }; + for (const golem of Object.values(GOLEMS_DEF)) { + const label = getTierLabel(golem.tier); + const key = label.toLowerCase(); + if (groups[key]) { + groups[key].push(golem); + } else { + // tier 4 golems go into hybrid + groups.hybrid.push(golem); + } + } + return groups; + }, []); + + const handleToggle = useCallback((id: string) => { + toggleGolem(id); + }, [toggleGolem]); + + // Golem slot info + const fabricatorLevel = attunements.fabricator?.level ?? 0; + const golemSlots = getGolemSlots(fabricatorLevel); + const enabledCount = golemancy.enabledGolems.length; + + if (!mounted) { + return ( +
+ Loading golemancy… +
+ ); + } + + const activeTierGolems = golemsByTier[activeTier] ?? []; + + return ( + +
+ {/* Header info */} +
+

+ Configure your golem loadout. Enabled golems are automatically summoned + when entering the spire if you can afford the cost. +

+
+ + Slots: {enabledCount}/{golemSlots > 0 ? golemSlots : '—'} + + + Summoned: {golemancy.summonedGolems.length} + +
+
+ + {/* Tier tabs */} +
+ {TIERS.map((tier) => { + const count = golemsByTier[tier.key]?.length ?? 0; + return ( + + ); + })} +
+ + {/* Golem cards */} + + {activeTierGolems.length === 0 ? ( +
+ No golems in this tier. +
+ ) : ( +
+ {activeTierGolems.map((golem) => { + const unlocked = isGolemUnlocked(golem.id, attunementLookup, unlockedElements); + const enabled = golemancy.enabledGolems.includes(golem.id); + const summoned = golemancy.summonedGolems.some(g => g.golemId === golem.id); + const canAfford = canAffordGolemSummon(golem.id, rawMana, elements); + return ( + + ); + })} +
+ )} +
+
+
+ ); +}; + +GolemancyTab.displayName = 'GolemancyTab'; diff --git a/src/components/game/tabs/index.ts b/src/components/game/tabs/index.ts index 65209ce..c788694 100644 --- a/src/components/game/tabs/index.ts +++ b/src/components/game/tabs/index.ts @@ -9,3 +9,4 @@ export { AchievementsTab } from './AchievementsTab'; export { AttunementsTab } from './AttunementsTab'; export { PrestigeTab } from './PrestigeTab'; export { EquipmentTab } from './EquipmentTab'; +export { GolemancyTab } from './GolemancyTab';