feat: recreate Equipment Tab with equip/unequip gear management
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
# Circular Dependencies
|
# Circular Dependencies
|
||||||
Generated: 2026-05-19T16:29:33.501Z
|
Generated: 2026-05-19T18:19:35.896Z
|
||||||
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-19T16:29:32.045Z",
|
"generated": "2026-05-19T18:19:34.562Z",
|
||||||
"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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ Mana-Loop/
|
|||||||
│ │ │ │ │ ├── GolemDebugSection.tsx
|
│ │ │ │ │ ├── GolemDebugSection.tsx
|
||||||
│ │ │ │ │ ├── PactDebugSection.tsx
|
│ │ │ │ │ ├── PactDebugSection.tsx
|
||||||
│ │ │ │ │ └── SpireDebugSection.tsx
|
│ │ │ │ │ └── SpireDebugSection.tsx
|
||||||
|
│ │ │ │ ├── EquipmentTab/
|
||||||
|
│ │ │ │ │ ├── EquipmentEffectsSummary.tsx
|
||||||
|
│ │ │ │ │ ├── EquipmentSlotGrid.tsx
|
||||||
|
│ │ │ │ │ └── InventoryList.tsx
|
||||||
│ │ │ │ ├── StatsTab/
|
│ │ │ │ ├── StatsTab/
|
||||||
│ │ │ │ │ ├── CombatStatsSection.tsx
|
│ │ │ │ │ ├── CombatStatsSection.tsx
|
||||||
│ │ │ │ │ ├── ElementStatsSection.tsx
|
│ │ │ │ │ ├── ElementStatsSection.tsx
|
||||||
@@ -111,6 +115,8 @@ Mana-Loop/
|
|||||||
│ │ │ │ ├── DebugTab.test.ts
|
│ │ │ │ ├── DebugTab.test.ts
|
||||||
│ │ │ │ ├── DebugTab.tsx
|
│ │ │ │ ├── DebugTab.tsx
|
||||||
│ │ │ │ ├── DisciplinesTab.tsx
|
│ │ │ │ ├── DisciplinesTab.tsx
|
||||||
|
│ │ │ │ ├── EquipmentTab.test.ts
|
||||||
|
│ │ │ │ ├── EquipmentTab.tsx
|
||||||
│ │ │ │ ├── PrestigeTab.test.ts
|
│ │ │ │ ├── PrestigeTab.test.ts
|
||||||
│ │ │ │ ├── PrestigeTab.tsx
|
│ │ │ │ ├── PrestigeTab.tsx
|
||||||
│ │ │ │ ├── SpellsTab.tsx
|
│ │ │ │ ├── SpellsTab.tsx
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const DebugTab = lazy(() => import('@/components/game/tabs').then(module => ({ d
|
|||||||
const AchievementsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.AchievementsTab })));
|
const AchievementsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.AchievementsTab })));
|
||||||
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 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>;
|
||||||
|
|
||||||
@@ -241,6 +242,7 @@ export default function ManaLoopGame() {
|
|||||||
<TabsTrigger value="attunements" className="text-xs px-2 py-1">⚗️ Attunements</TabsTrigger>
|
<TabsTrigger value="attunements" className="text-xs px-2 py-1">⚗️ Attunements</TabsTrigger>
|
||||||
<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>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="spells">
|
<TabsContent value="spells">
|
||||||
@@ -302,6 +304,14 @@ export default function ManaLoopGame() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="equipment">
|
||||||
|
<ErrorBoundary fallback={<div className="p-4 text-red-400">equipment tab failed to load.</div>}>
|
||||||
|
<Suspense fallback={<TabLoadingFallback />}>
|
||||||
|
<EquipmentTab />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// ─── Test: EquipmentTab barrel export ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('EquipmentTab module structure', () => {
|
||||||
|
it('exports EquipmentTab from barrel index', async () => {
|
||||||
|
const mod = await import('./EquipmentTab');
|
||||||
|
expect(mod.EquipmentTab).toBeDefined();
|
||||||
|
expect(typeof mod.EquipmentTab).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('EquipmentTab has correct displayName', async () => {
|
||||||
|
const { EquipmentTab } = await import('./EquipmentTab');
|
||||||
|
expect(EquipmentTab.displayName).toBe('EquipmentTab');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Barrel export includes EquipmentTab ─────────────────────────────────
|
||||||
|
|
||||||
|
describe('Tab barrel export', () => {
|
||||||
|
it('includes EquipmentTab in the tabs index', async () => {
|
||||||
|
const mod = await import('@/components/game/tabs');
|
||||||
|
expect(mod.EquipmentTab).toBeDefined();
|
||||||
|
expect(typeof mod.EquipmentTab).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Equipment slot definitions ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Equipment slot definitions', () => {
|
||||||
|
it('has exactly 8 equipment slots', async () => {
|
||||||
|
const { EQUIPMENT_SLOTS } = await import('@/lib/game/data/equipment');
|
||||||
|
expect(EQUIPMENT_SLOTS).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all slots have display names', async () => {
|
||||||
|
const { SLOT_NAMES, EQUIPMENT_SLOTS } = await import('@/lib/game/data/equipment');
|
||||||
|
for (const slot of EQUIPMENT_SLOTS) {
|
||||||
|
expect(SLOT_NAMES[slot]).toBeDefined();
|
||||||
|
expect(SLOT_NAMES[slot].length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes mainHand, offHand, head, body, hands, feet, accessory1, accessory2', async () => {
|
||||||
|
const { EQUIPMENT_SLOTS } = await import('@/lib/game/data/equipment');
|
||||||
|
expect(EQUIPMENT_SLOTS).toContain('mainHand');
|
||||||
|
expect(EQUIPMENT_SLOTS).toContain('offHand');
|
||||||
|
expect(EQUIPMENT_SLOTS).toContain('head');
|
||||||
|
expect(EQUIPMENT_SLOTS).toContain('body');
|
||||||
|
expect(EQUIPMENT_SLOTS).toContain('hands');
|
||||||
|
expect(EQUIPMENT_SLOTS).toContain('feet');
|
||||||
|
expect(EQUIPMENT_SLOTS).toContain('accessory1');
|
||||||
|
expect(EQUIPMENT_SLOTS).toContain('accessory2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Equipment type definitions ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Equipment type definitions', () => {
|
||||||
|
it('all equipment types have required fields', async () => {
|
||||||
|
const { EQUIPMENT_TYPES } = await import('@/lib/game/data/equipment');
|
||||||
|
for (const [id, type] of Object.entries(EQUIPMENT_TYPES)) {
|
||||||
|
expect(type.id).toBe(id);
|
||||||
|
expect(type.name).toBeTruthy();
|
||||||
|
expect(type.category).toBeTruthy();
|
||||||
|
expect(type.slot).toBeTruthy();
|
||||||
|
expect(type.baseCapacity).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accessory category types can equip in accessory slots', async () => {
|
||||||
|
const { EQUIPMENT_TYPES, getValidSlotsForEquipmentType } = await import('@/lib/game/data/equipment');
|
||||||
|
const accessories = Object.values(EQUIPMENT_TYPES).filter((t) => t.category === 'accessory');
|
||||||
|
expect(accessories.length).toBeGreaterThan(0);
|
||||||
|
for (const acc of accessories) {
|
||||||
|
const slots = getValidSlotsForEquipmentType(acc);
|
||||||
|
expect(slots).toContain('accessory1');
|
||||||
|
expect(slots).toContain('accessory2');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Starting equipment ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Starting equipment', () => {
|
||||||
|
it('createStartingEquipment returns valid equippedInstances', async () => {
|
||||||
|
const { createStartingEquipment } = await import('@/lib/game/crafting-slice');
|
||||||
|
const { equippedInstances, equipmentInstances } = createStartingEquipment();
|
||||||
|
|
||||||
|
expect(equippedInstances.mainHand).toBeTruthy();
|
||||||
|
expect(equippedInstances.body).toBeTruthy();
|
||||||
|
expect(equippedInstances.feet).toBeTruthy();
|
||||||
|
expect(equippedInstances.offHand).toBeNull();
|
||||||
|
expect(equippedInstances.head).toBeNull();
|
||||||
|
expect(equippedInstances.hands).toBeNull();
|
||||||
|
expect(equippedInstances.accessory1).toBeNull();
|
||||||
|
expect(equippedInstances.accessory2).toBeNull();
|
||||||
|
|
||||||
|
expect(Object.keys(equipmentInstances).length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starting equipment instances have valid fields', async () => {
|
||||||
|
const { createStartingEquipment } = await import('@/lib/game/crafting-slice');
|
||||||
|
const { equipmentInstances } = createStartingEquipment();
|
||||||
|
|
||||||
|
for (const instance of Object.values(equipmentInstances)) {
|
||||||
|
expect(instance.instanceId).toBeTruthy();
|
||||||
|
expect(instance.typeId).toBeTruthy();
|
||||||
|
expect(instance.name).toBeTruthy();
|
||||||
|
expect(instance.rarity).toBe('common');
|
||||||
|
expect(instance.quality).toBe(100);
|
||||||
|
expect(instance.usedCapacity).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(instance.totalCapacity).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Equipment actions ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Equipment actions', () => {
|
||||||
|
it('equipItem is a function', async () => {
|
||||||
|
const { equipItem } = await import('@/lib/game/crafting-actions/equipment-actions');
|
||||||
|
expect(typeof equipItem).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unequipItem is a function', async () => {
|
||||||
|
const { unequipItem } = await import('@/lib/game/crafting-actions/equipment-actions');
|
||||||
|
expect(typeof unequipItem).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteEquipmentInstance is a function', async () => {
|
||||||
|
const { deleteEquipmentInstance } = await import('@/lib/game/crafting-actions/equipment-actions');
|
||||||
|
expect(typeof deleteEquipmentInstance).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createEquipmentInstance is a function', async () => {
|
||||||
|
const { createEquipmentInstance } = await import('@/lib/game/crafting-actions/equipment-actions');
|
||||||
|
expect(typeof createEquipmentInstance).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Equipment effects ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Equipment effects computation', () => {
|
||||||
|
it('computeEquipmentEffects returns empty for no equipment', async () => {
|
||||||
|
const { computeEquipmentEffects } = await import('@/lib/game/effects');
|
||||||
|
const result = computeEquipmentEffects({}, { mainHand: null, offHand: null, head: null, body: null, hands: null, feet: null, accessory1: null, accessory2: null });
|
||||||
|
expect(Object.keys(result.bonuses)).toHaveLength(0);
|
||||||
|
expect(Object.keys(result.multipliers)).toHaveLength(0);
|
||||||
|
expect(result.specials.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computeEquipmentEffects detects enchantment bonuses', async () => {
|
||||||
|
const { computeEquipmentEffects } = await import('@/lib/game/effects');
|
||||||
|
const instances = {
|
||||||
|
test1: {
|
||||||
|
instanceId: 'test1',
|
||||||
|
typeId: 'basicStaff',
|
||||||
|
name: 'Test Staff',
|
||||||
|
enchantments: [{ effectId: 'spell_manaBolt', stacks: 1, actualCost: 50 }],
|
||||||
|
usedCapacity: 50,
|
||||||
|
totalCapacity: 50,
|
||||||
|
rarity: 'common' as const,
|
||||||
|
quality: 100,
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const equipped = { mainHand: 'test1', offHand: null, head: null, body: null, hands: null, feet: null, accessory1: null, accessory2: null };
|
||||||
|
const result = computeEquipmentEffects(instances, equipped);
|
||||||
|
// Should at least not crash and return a valid structure
|
||||||
|
expect(result).toHaveProperty('bonuses');
|
||||||
|
expect(result).toHaveProperty('multipliers');
|
||||||
|
expect(result).toHaveProperty('specials');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: File size limits ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('File size limits (400 lines max)', () => {
|
||||||
|
it('EquipmentTab.tsx is under 400 lines', async () => {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const path = await import('path');
|
||||||
|
const filePath = path.join(__dirname, 'EquipmentTab.tsx');
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const lines = content.split('\n').length;
|
||||||
|
expect(lines).toBeLessThan(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('EquipmentSlotGrid.tsx is under 400 lines', async () => {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const path = await import('path');
|
||||||
|
const filePath = path.join(__dirname, 'EquipmentTab/EquipmentSlotGrid.tsx');
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const lines = content.split('\n').length;
|
||||||
|
expect(lines).toBeLessThan(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('InventoryList.tsx is under 400 lines', async () => {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const path = await import('path');
|
||||||
|
const filePath = path.join(__dirname, 'EquipmentTab/InventoryList.tsx');
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const lines = content.split('\n').length;
|
||||||
|
expect(lines).toBeLessThan(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('EquipmentEffectsSummary.tsx is under 400 lines', async () => {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const path = await import('path');
|
||||||
|
const filePath = path.join(__dirname, 'EquipmentTab/EquipmentEffectsSummary.tsx');
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const lines = content.split('\n').length;
|
||||||
|
expect(lines).toBeLessThan(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useCraftingStore } from '@/lib/game/stores/craftingStore';
|
||||||
|
import { equipItem, unequipItem, deleteEquipmentInstance } from '@/lib/game/crafting-actions/equipment-actions';
|
||||||
|
import type { EquipmentSlot } from '@/lib/game/types';
|
||||||
|
import { DebugName } from '@/components/game/debug/debug-context';
|
||||||
|
import { EquipmentSlotGrid } from './EquipmentTab/EquipmentSlotGrid';
|
||||||
|
import { InventoryList } from './EquipmentTab/InventoryList';
|
||||||
|
import { EquipmentEffectsSummary } from './EquipmentTab/EquipmentEffectsSummary';
|
||||||
|
|
||||||
|
export function EquipmentTab() {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
|
||||||
|
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
|
||||||
|
const storeEquipItem = useCraftingStore((s) => s.equipItem);
|
||||||
|
const storeUnequipItem = useCraftingStore((s) => s.unequipItem);
|
||||||
|
const storeDeleteEquipment = useCraftingStore((s) => s.deleteEquipmentInstance);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEquip = useCallback(
|
||||||
|
(instanceId: string, slot: EquipmentSlot) => {
|
||||||
|
storeEquipItem(instanceId, slot);
|
||||||
|
},
|
||||||
|
[storeEquipItem]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUnequip = useCallback(
|
||||||
|
(slot: EquipmentSlot) => {
|
||||||
|
storeUnequipItem(slot);
|
||||||
|
},
|
||||||
|
[storeUnequipItem]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(instanceId: string) => {
|
||||||
|
storeDeleteEquipment(instanceId);
|
||||||
|
},
|
||||||
|
[storeDeleteEquipment]
|
||||||
|
);
|
||||||
|
|
||||||
|
const inventoryItems = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.entries(equipmentInstances).filter(
|
||||||
|
([id]) => !Object.values(equippedInstances).includes(id)
|
||||||
|
),
|
||||||
|
[equipmentInstances, equippedInstances]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8 text-[var(--text-muted)]">
|
||||||
|
Loading equipment…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DebugName name="EquipmentTab">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)] mb-3">Equipped Gear</h2>
|
||||||
|
<EquipmentSlotGrid
|
||||||
|
equippedInstances={equippedInstances}
|
||||||
|
equipmentInstances={equipmentInstances}
|
||||||
|
onUnequip={handleUnequip}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EquipmentEffectsSummary
|
||||||
|
equipmentInstances={equipmentInstances}
|
||||||
|
equippedInstances={equippedInstances}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)] mb-3">
|
||||||
|
Inventory ({inventoryItems.length})
|
||||||
|
</h2>
|
||||||
|
<InventoryList
|
||||||
|
inventoryItems={inventoryItems}
|
||||||
|
equippedInstances={equippedInstances}
|
||||||
|
onEquip={handleEquip}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DebugName>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EquipmentTab.displayName = 'EquipmentTab';
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { computeEquipmentEffects } from '@/lib/game/effects';
|
||||||
|
import type { EquipmentInstance } from '@/lib/game/types';
|
||||||
|
|
||||||
|
interface EquipmentEffectsSummaryProps {
|
||||||
|
equipmentInstances: Record<string, EquipmentInstance>;
|
||||||
|
equippedInstances: Record<string, string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BONUS_LABELS: Record<string, string> = {
|
||||||
|
maxMana: 'Max Mana',
|
||||||
|
regen: 'Mana Regen',
|
||||||
|
clickMana: 'Click Mana',
|
||||||
|
baseDamage: 'Base Damage',
|
||||||
|
elementCap: 'Element Cap',
|
||||||
|
critChance: 'Crit Chance',
|
||||||
|
attackSpeed: 'Attack Speed',
|
||||||
|
meditationEfficiency: 'Meditation Efficiency',
|
||||||
|
studySpeed: 'Study Speed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MULT_LABELS: Record<string, string> = {
|
||||||
|
maxMana: 'Max Mana',
|
||||||
|
regen: 'Mana Regen',
|
||||||
|
clickMana: 'Click Mana',
|
||||||
|
baseDamage: 'Base Damage',
|
||||||
|
attackSpeed: 'Attack Speed',
|
||||||
|
elementCap: 'Element Cap',
|
||||||
|
meditationEfficiency: 'Meditation Efficiency',
|
||||||
|
studySpeed: 'Study Speed',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EquipmentEffectsSummary({ equipmentInstances, equippedInstances }: EquipmentEffectsSummaryProps) {
|
||||||
|
const { bonuses, multipliers, specials } = computeEquipmentEffects(equipmentInstances, equippedInstances);
|
||||||
|
|
||||||
|
const bonusEntries = Object.entries(bonuses).filter(([, v]) => v !== 0);
|
||||||
|
const multEntries = Object.entries(multipliers).filter(([, v]) => v !== 1);
|
||||||
|
const specialEntries = Array.from(specials);
|
||||||
|
|
||||||
|
if (bonusEntries.length === 0 && multEntries.length === 0 && specialEntries.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 rounded border border-[var(--border-default)] bg-[var(--bg-sunken)] space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Equipment Effects</h3>
|
||||||
|
|
||||||
|
{bonusEntries.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-[var(--text-muted)]">Bonuses</div>
|
||||||
|
{bonusEntries.map(([key, value]) => (
|
||||||
|
<div key={key} className="flex justify-between text-xs">
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
{BONUS_LABELS[key] || key}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--color-success)]">
|
||||||
|
{value > 0 ? '+' : ''}{typeof value === 'number' && !Number.isInteger(value) ? value.toFixed(2) : value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{multEntries.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-[var(--text-muted)]">Multipliers</div>
|
||||||
|
{multEntries.map(([key, value]) => (
|
||||||
|
<div key={key} className="flex justify-between text-xs">
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
{MULT_LABELS[key] || key}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--color-info)]">
|
||||||
|
×{typeof value === 'number' ? value.toFixed(2) : value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{specialEntries.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-[var(--text-muted)]">Specials</div>
|
||||||
|
{specialEntries.map((id) => (
|
||||||
|
<div key={id} className="text-xs text-[var(--color-warning)]">
|
||||||
|
★ {id}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Package } from 'lucide-react';
|
||||||
|
import type { EquipmentInstance, EquipmentSlot } from '@/lib/game/types';
|
||||||
|
import { EQUIPMENT_TYPES, SLOT_NAMES } from '@/lib/game/data/equipment';
|
||||||
|
import { RARITY_CSS_VAR } from '@/components/game/LootInventory/types';
|
||||||
|
import { CATEGORY_ICONS } from '@/components/game/LootInventory/icons';
|
||||||
|
import { ActionButton } from '@/components/ui/action-button';
|
||||||
|
|
||||||
|
interface EquipmentSlotGridProps {
|
||||||
|
equippedInstances: Record<string, string | null>;
|
||||||
|
equipmentInstances: Record<string, EquipmentInstance>;
|
||||||
|
onUnequip: (slot: EquipmentSlot) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLOTS: EquipmentSlot[] = ['mainHand', 'offHand', 'head', 'body', 'hands', 'feet', 'accessory1', 'accessory2'];
|
||||||
|
|
||||||
|
export function EquipmentSlotGrid({ equippedInstances, equipmentInstances, onUnequip }: EquipmentSlotGridProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{SLOTS.map((slot) => {
|
||||||
|
const instanceId = equippedInstances[slot];
|
||||||
|
const instance = instanceId ? equipmentInstances[instanceId] : null;
|
||||||
|
|
||||||
|
if (instance) {
|
||||||
|
const type = EQUIPMENT_TYPES[instance.typeId];
|
||||||
|
const Icon = type ? CATEGORY_ICONS[type.category] || Package : Package;
|
||||||
|
const rarityColor = RARITY_CSS_VAR[instance.rarity] || 'var(--rarity-common)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={slot}
|
||||||
|
className="p-3 rounded border bg-[var(--bg-sunken)] space-y-2"
|
||||||
|
style={{ borderColor: rarityColor }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-[var(--text-muted)]">{SLOT_NAMES[slot]}</span>
|
||||||
|
<Icon className="w-4 h-4" style={{ color: rarityColor }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold truncate" style={{ color: rarityColor }}>
|
||||||
|
{instance.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-secondary)]">
|
||||||
|
{type?.name || instance.typeId}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-muted)]">
|
||||||
|
{instance.usedCapacity}/{instance.totalCapacity} cap
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-muted)]">
|
||||||
|
{instance.enchantments.length} enchant{instance.enchantments.length !== 1 ? 's' : ''} • {instance.quality}% quality
|
||||||
|
</div>
|
||||||
|
<ActionButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full text-xs"
|
||||||
|
onClick={() => onUnequip(slot)}
|
||||||
|
>
|
||||||
|
Unequip
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={slot}
|
||||||
|
className="p-3 rounded border border-dashed border-[var(--border-default)] bg-[var(--bg-sunken)] space-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-[var(--text-muted)]">{SLOT_NAMES[slot]}</span>
|
||||||
|
<Package className="w-4 h-4 text-[var(--text-muted)]" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--text-muted)] italic">Empty</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Package, Trash2 } from 'lucide-react';
|
||||||
|
import type { EquipmentInstance, EquipmentSlot } from '@/lib/game/types';
|
||||||
|
import { EQUIPMENT_TYPES, getValidSlotsForEquipmentType } from '@/lib/game/data/equipment';
|
||||||
|
import { RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from '@/components/game/LootInventory/types';
|
||||||
|
import { CATEGORY_ICONS } from '@/components/game/LootInventory/icons';
|
||||||
|
import { ActionButton } from '@/components/ui/action-button';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
|
interface InventoryListProps {
|
||||||
|
inventoryItems: [string, EquipmentInstance][];
|
||||||
|
equippedInstances: Record<string, string | null>;
|
||||||
|
onEquip: (instanceId: string, slot: EquipmentSlot) => boolean;
|
||||||
|
onDelete: (instanceId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventoryList({ inventoryItems, equippedInstances, onEquip, onDelete }: InventoryListProps) {
|
||||||
|
const [selectedSlot, setSelectedSlot] = useState<Record<string, EquipmentSlot>>({});
|
||||||
|
|
||||||
|
if (inventoryItems.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-[var(--text-muted)] italic text-center py-4">
|
||||||
|
No items in inventory. Craft or find equipment to fill your slots.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{inventoryItems.map(([instanceId, instance]) => {
|
||||||
|
const type = EQUIPMENT_TYPES[instance.typeId];
|
||||||
|
const Icon = type ? CATEGORY_ICONS[type.category] || Package : Package;
|
||||||
|
const rarityColor = RARITY_CSS_VAR[instance.rarity] || 'var(--rarity-common)';
|
||||||
|
const rarityGlow = RARITY_GLOW_CSS_VAR[instance.rarity] || 'var(--rarity-common-glow)';
|
||||||
|
const validSlots = type ? getValidSlotsForEquipmentType(type) : [];
|
||||||
|
const chosenSlot = selectedSlot[instanceId];
|
||||||
|
const availableSlots = validSlots.filter((s) => !equippedInstances[s]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={instanceId}
|
||||||
|
className="p-3 rounded border bg-[var(--bg-sunken)] group flex items-center gap-3"
|
||||||
|
style={{
|
||||||
|
borderColor: rarityColor,
|
||||||
|
backgroundColor: rarityGlow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 shrink-0" style={{ color: rarityColor }} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-semibold truncate" style={{ color: rarityColor }}>
|
||||||
|
{instance.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-secondary)]">
|
||||||
|
{type?.name || instance.typeId} • {instance.usedCapacity}/{instance.totalCapacity} cap
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-muted)] capitalize">
|
||||||
|
{instance.rarity} • {instance.enchantments.length} enchant{instance.enchantments.length !== 1 ? 's' : ''} • {instance.quality}% quality
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{availableSlots.length > 1 ? (
|
||||||
|
<select
|
||||||
|
className="text-xs bg-[var(--bg-base)] border border-[var(--border-default)] rounded px-2 py-1 text-[var(--text-primary)]"
|
||||||
|
value={chosenSlot || ''}
|
||||||
|
onChange={(e) => setSelectedSlot((prev) => ({ ...prev, [instanceId]: e.target.value as EquipmentSlot }))}
|
||||||
|
>
|
||||||
|
<option value="">Select slot</option>
|
||||||
|
{availableSlots.map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
<ActionButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
const slot = availableSlots.length === 1 ? availableSlots[0] : chosenSlot;
|
||||||
|
if (slot) {
|
||||||
|
onEquip(instanceId, slot);
|
||||||
|
setSelectedSlot((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[instanceId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={availableSlots.length === 0 || (availableSlots.length > 1 && !chosenSlot)}
|
||||||
|
>
|
||||||
|
Equip
|
||||||
|
</ActionButton>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<ActionButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</ActionButton>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete {instance.name}?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. The item will be permanently destroyed.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => onDelete(instanceId)}>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,3 +8,4 @@ export { DebugTab } from './DebugTab';
|
|||||||
export { AchievementsTab } from './AchievementsTab';
|
export { AchievementsTab } from './AchievementsTab';
|
||||||
export { AttunementsTab } from './AttunementsTab';
|
export { AttunementsTab } from './AttunementsTab';
|
||||||
export { PrestigeTab } from './PrestigeTab';
|
export { PrestigeTab } from './PrestigeTab';
|
||||||
|
export { EquipmentTab } from './EquipmentTab';
|
||||||
|
|||||||
Reference in New Issue
Block a user