fix: visually block off-hand slot when 2H weapon equipped in main hand
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m34s

- Add getTwoHandedBlocker() helper to EquipmentSlotGrid that detects when
  mainHand has a two-handed weapon and returns the weapon name
- Render offHand slot with Lock icon + 'Blocked: <weapon name>' label +
  reduced opacity when blocked, distinct from normal 'Empty' dashed-border slot
- Add regression test verifying all 4 two-handed types are correctly flagged
  and non-two-handed types remain unblocked (11 tests)
This commit is contained in:
2026-06-09 18:48:04 +02:00
parent 4a282a2121
commit 28d39a61ba
5 changed files with 109 additions and 3 deletions
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
import { isTwoHanded } from '@/lib/game/crafting-utils';
// ─── Regression test: Off-hand slot visual blocking for 2H weapons ─────────
// https://gitea.tailf367e3.ts.net/Anexim/Mana-Loop/issues/341
describe('EquipmentSlotGrid — two-handed weapon off-hand blocking', () => {
// The visual blocking logic in EquipmentSlotGrid uses getTwoHandedBlocker()
// which checks: if slot === 'offHand' and mainHand has a two-handed weapon,
// return the weapon name. We test the underlying data + isTwoHanded guard
// that the component relies on.
it('basicStaff is marked two-handed in equipment data', () => {
// basicStaff is the starting weapon — this is the primary case for the bug
expect(isTwoHanded('basicStaff')).toBe(true);
});
it('oakStaff is marked two-handed in equipment data', () => {
expect(isTwoHanded('oakStaff')).toBe(true);
});
it('arcanistStaff is marked two-handed in equipment data', () => {
expect(isTwoHanded('arcanistStaff')).toBe(true);
});
it('battlestaff is marked two-handed in equipment data', () => {
expect(isTwoHanded('battlestaff')).toBe(true);
});
it('apprenticeWand is NOT two-handed', () => {
expect(isTwoHanded('apprenticeWand')).toBe(false);
});
it('crystalWand is NOT two-handed', () => {
expect(isTwoHanded('crystalWand')).toBe(false);
});
it('ironBlade (sword) is NOT two-handed', () => {
expect(isTwoHanded('ironBlade')).toBe(false);
});
it('basicCatalyst is NOT two-handed', () => {
expect(isTwoHanded('basicCatalyst')).toBe(false);
});
it('isTwoHanded returns false for unknown type IDs', () => {
expect(isTwoHanded('nonexistent_type')).toBe(false);
});
it('exactly 4 equipment types are two-handed', () => {
const twoHandedTypes = Object.values(EQUIPMENT_TYPES).filter((t) => t.twoHanded);
expect(twoHandedTypes.length).toBe(4);
const ids = twoHandedTypes.map((t) => t.id);
expect(ids).toContain('basicStaff');
expect(ids).toContain('oakStaff');
expect(ids).toContain('arcanistStaff');
expect(ids).toContain('battlestaff');
});
it('all two-handed types are in the mainHand slot category', () => {
const twoHandedTypes = Object.values(EQUIPMENT_TYPES).filter((t) => t.twoHanded);
for (const type of twoHandedTypes) {
expect(type.slot).toBe('mainHand');
}
});
});
@@ -1,6 +1,6 @@
'use client';
import { Package } from 'lucide-react';
import { Lock, 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';
@@ -16,6 +16,27 @@ interface EquipmentSlotGridProps {
const SLOTS: EquipmentSlot[] = ['mainHand', 'offHand', 'head', 'body', 'hands', 'feet', 'accessory1', 'accessory2'];
/**
* Determine if a slot is blocked by a two-handed weapon in mainHand.
* Returns the type name of the blocking weapon, or null if not blocked.
*/
function getTwoHandedBlocker(
slot: EquipmentSlot,
equippedInstances: Record<string, string | null>,
equipmentInstances: Record<string, EquipmentInstance>,
): string | null {
if (slot !== 'offHand') return null;
const mainHandId = equippedInstances.mainHand;
if (!mainHandId) return null;
const mainHandInstance = equipmentInstances[mainHandId];
if (!mainHandInstance) return null;
const mainHandType = EQUIPMENT_TYPES[mainHandInstance.typeId];
if (mainHandType?.twoHanded) {
return mainHandType.name;
}
return null;
}
export function EquipmentSlotGrid({ equippedInstances, equipmentInstances, onUnequip }: EquipmentSlotGridProps) {
return (
<DebugName name="EquipmentSlotGrid">
@@ -23,6 +44,7 @@ export function EquipmentSlotGrid({ equippedInstances, equipmentInstances, onUne
{SLOTS.map((slot) => {
const instanceId = equippedInstances[slot];
const instance = instanceId ? equipmentInstances[instanceId] : null;
const blocker = getTwoHandedBlocker(slot, equippedInstances, equipmentInstances);
if (instance) {
const type = EQUIPMENT_TYPES[instance.typeId];
@@ -60,6 +82,22 @@ export function EquipmentSlotGrid({ equippedInstances, equipmentInstances, onUne
);
}
// Blocked by two-handed weapon
if (blocker) {
return (
<div
key={slot}
className="p-3 rounded border border-dashed border-[var(--border-muted)] bg-[var(--bg-sunken)]/50 space-y-2 opacity-60"
>
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--text-muted)]">{SLOT_NAMES[slot]}</span>
<Lock className="w-4 h-4 text-[var(--text-muted)]" />
</div>
<div className="text-sm text-[var(--text-muted)] italic">Blocked: {blocker}</div>
</div>
);
}
return (
<div
key={slot}