feat: recreate Golemancy tab with golem loadout configuration
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m17s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m17s
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
# Circular Dependencies
|
# 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.
|
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
|
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-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.",
|
"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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ Mana-Loop/
|
|||||||
│ │ │ │ ├── DisciplinesTab.tsx
|
│ │ │ │ ├── DisciplinesTab.tsx
|
||||||
│ │ │ │ ├── EquipmentTab.test.ts
|
│ │ │ │ ├── EquipmentTab.test.ts
|
||||||
│ │ │ │ ├── EquipmentTab.tsx
|
│ │ │ │ ├── EquipmentTab.tsx
|
||||||
|
│ │ │ │ ├── GolemancyTab.test.ts
|
||||||
|
│ │ │ │ ├── GolemancyTab.tsx
|
||||||
│ │ │ │ ├── PrestigeTab.test.ts
|
│ │ │ │ ├── PrestigeTab.test.ts
|
||||||
│ │ │ │ ├── PrestigeTab.tsx
|
│ │ │ │ ├── PrestigeTab.tsx
|
||||||
│ │ │ │ ├── SpellsTab.tsx
|
│ │ │ │ ├── SpellsTab.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 AttunementsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.AttunementsTab })));
|
||||||
const PrestigeTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.PrestigeTab })));
|
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 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 = () => <div className="p-4 text-center text-gray-400">Loading...</div>;
|
const TabLoadingFallback = () => <div className="p-4 text-center text-gray-400">Loading...</div>;
|
||||||
|
|
||||||
@@ -243,6 +244,7 @@ export default function ManaLoopGame() {
|
|||||||
<TabsTrigger value="achievements" className="text-xs px-2 py-1">🏆 Achievements</TabsTrigger>
|
<TabsTrigger value="achievements" className="text-xs px-2 py-1">🏆 Achievements</TabsTrigger>
|
||||||
<TabsTrigger value="prestige" className="text-xs px-2 py-1">✨ Prestige</TabsTrigger>
|
<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="equipment" className="text-xs px-2 py-1">⚔️ Equipment</TabsTrigger>
|
||||||
|
<TabsTrigger value="golemancy" className="text-xs px-2 py-1">🗿 Golemancy</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="spells">
|
<TabsContent value="spells">
|
||||||
@@ -312,6 +314,14 @@ export default function ManaLoopGame() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="golemancy">
|
||||||
|
<ErrorBoundary fallback={<div className="p-4 text-red-400">golemancy tab failed to load.</div>}>
|
||||||
|
<Suspense fallback={<TabLoadingFallback />}>
|
||||||
|
<GolemancyTab />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<GolemCardProps> = React.memo(({
|
||||||
|
golem,
|
||||||
|
unlocked,
|
||||||
|
enabled,
|
||||||
|
summoned,
|
||||||
|
canAfford,
|
||||||
|
onToggle,
|
||||||
|
}) => {
|
||||||
|
const elemColor = ELEMENTS[golem.baseManaType]?.color ?? '#888';
|
||||||
|
const elemSym = ELEMENTS[golem.baseManaType]?.sym ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'rounded-lg border p-4 space-y-3 transition-colors',
|
||||||
|
unlocked
|
||||||
|
? 'bg-gray-800/50 border-gray-600'
|
||||||
|
: 'bg-gray-900/50 border-gray-800 opacity-60',
|
||||||
|
summoned && 'ring-1 ring-green-500/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-semibold text-gray-100 truncate">{golem.name}</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">{golem.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<span
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: elemColor }}
|
||||||
|
title={golem.baseManaType}
|
||||||
|
>
|
||||||
|
{elemSym}
|
||||||
|
</span>
|
||||||
|
<Badge className={clsx('text-[10px] px-1.5 py-0', getTierColor(golem.tier))}>
|
||||||
|
T{golem.tier}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<span className="text-gray-500">DMG:</span> {golem.damage}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<span className="text-gray-500">SPD:</span> {golem.attackSpeed}/h
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<span className="text-gray-500">HP:</span> {golem.hp}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<span className="text-gray-500">AP:</span> {Math.round(golem.armorPierce * 100)}%
|
||||||
|
</div>
|
||||||
|
{golem.isAoe && (
|
||||||
|
<div className="col-span-2 text-gray-400">
|
||||||
|
<span className="text-gray-500">AoE:</span> {golem.aoeTargets} targets
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Costs */}
|
||||||
|
<div className="text-xs space-y-0.5">
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<span className="text-gray-500">Summon:</span>{' '}
|
||||||
|
{golem.summonCost.map((c, i) => (
|
||||||
|
<span key={i}>{formatCost(c)}{i < golem.summonCost.length - 1 ? ' + ' : ''}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<span className="text-gray-500">Upkeep:</span>{' '}
|
||||||
|
{golem.maintenanceCost.map((c, i) => (
|
||||||
|
<span key={i}>{formatCost(c)}{i < golem.maintenanceCost.length - 1 ? ' + ' : ''}</span>
|
||||||
|
))}/tick
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unlock requirement */}
|
||||||
|
{!unlocked && (
|
||||||
|
<div className="text-xs text-red-400">
|
||||||
|
🔒 Requires: {formatUnlockCondition(golem)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status + toggle */}
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<div className="text-xs">
|
||||||
|
{summoned ? (
|
||||||
|
<span className="text-green-400">● Summoned</span>
|
||||||
|
) : enabled ? (
|
||||||
|
<span className="text-yellow-400">○ Queued</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">— Idle</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(golem.id)}
|
||||||
|
disabled={!unlocked}
|
||||||
|
className={clsx(
|
||||||
|
'rounded px-3 py-1 text-xs font-medium transition-colors',
|
||||||
|
!unlocked
|
||||||
|
? 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
||||||
|
: enabled
|
||||||
|
? 'bg-red-600/80 text-white hover:bg-red-500'
|
||||||
|
: canAfford
|
||||||
|
? 'bg-green-600/80 text-white hover:bg-green-500'
|
||||||
|
: 'bg-blue-600/80 text-white hover:bg-blue-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!unlocked ? 'Locked' : enabled ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
GolemCard.displayName = 'GolemCard';
|
||||||
|
|
||||||
|
// ─── Main Tab ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const GolemancyTab: React.FC = () => {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [activeTier, setActiveTier] = useState<string>('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<string, { active: boolean; level: number }> = {};
|
||||||
|
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<string, GolemDef[]> = { 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 (
|
||||||
|
<div className="flex items-center justify-center p-8 text-gray-500">
|
||||||
|
Loading golemancy…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTierGolems = golemsByTier[activeTier] ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DebugName name="GolemancyTab">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header info */}
|
||||||
|
<div className="text-sm text-gray-400 space-y-1">
|
||||||
|
<p>
|
||||||
|
Configure your golem loadout. Enabled golems are automatically summoned
|
||||||
|
when entering the spire if you can afford the cost.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4 text-xs">
|
||||||
|
<span>
|
||||||
|
Slots: {enabledCount}/{golemSlots > 0 ? golemSlots : '—'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Summoned: {golemancy.summonedGolems.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tier tabs */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{TIERS.map((tier) => {
|
||||||
|
const count = golemsByTier[tier.key]?.length ?? 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tier.key}
|
||||||
|
onClick={() => setActiveTier(tier.key)}
|
||||||
|
className={clsx(
|
||||||
|
'rounded px-3 py-1 text-xs font-medium transition-colors',
|
||||||
|
activeTier === tier.key
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-gray-400 hover:text-gray-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tier.label} ({count})
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Golem cards */}
|
||||||
|
<ScrollArea className="h-[500px] rounded border border-gray-700 p-3">
|
||||||
|
{activeTierGolems.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-500 py-8">
|
||||||
|
No golems in this tier.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{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 (
|
||||||
|
<GolemCard
|
||||||
|
key={golem.id}
|
||||||
|
golem={golem}
|
||||||
|
unlocked={unlocked}
|
||||||
|
enabled={enabled}
|
||||||
|
summoned={summoned}
|
||||||
|
canAfford={canAfford}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</DebugName>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
GolemancyTab.displayName = 'GolemancyTab';
|
||||||
@@ -9,3 +9,4 @@ export { AchievementsTab } from './AchievementsTab';
|
|||||||
export { AttunementsTab } from './AttunementsTab';
|
export { AttunementsTab } from './AttunementsTab';
|
||||||
export { PrestigeTab } from './PrestigeTab';
|
export { PrestigeTab } from './PrestigeTab';
|
||||||
export { EquipmentTab } from './EquipmentTab';
|
export { EquipmentTab } from './EquipmentTab';
|
||||||
|
export { GolemancyTab } from './GolemancyTab';
|
||||||
|
|||||||
Reference in New Issue
Block a user