> ## Documentation Index
> Fetch the complete documentation index at: https://hyperscape-ai.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Inventory System

> Player inventory management, item stacking, context menus, and persistence

# Inventory System

The inventory system manages player item storage with 28 slots (OSRS standard), stackable item support, drag-and-drop operations, OSRS-style context menus, and database persistence.

<Info>
  Inventory code lives in `packages/shared/src/systems/shared/character/InventorySystem.ts` and `packages/client/src/game/systems/InventoryActionDispatcher.ts`.
</Info>

## Core Constants

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From InventorySystem.ts
private readonly MAX_INVENTORY_SLOTS = 28;      // OSRS standard
private readonly AUTO_SAVE_INTERVAL = 30000;    // 30 seconds auto-save
private readonly MAX_COINS = 2147483647;        // Max 32-bit signed integer (OSRS cap)
```

***

## Inventory Structure

Each player has a `PlayerInventory` containing:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
interface PlayerInventory {
  items: InventoryItem[];      // Array of items (up to 28 slots)
  coins: number;               // Coin pouch balance (separate from items)
}

interface InventoryItem {
  id: string;                  // Unique instance ID
  itemId: string;              // Reference to item definition
  quantity: number;            // Stack quantity (1 for non-stackable)
  slot: number;                // Slot index (0-27)
  metadata: unknown | null;    // Custom item data
}
```

***

## Money Pouch System

Hyperscape uses an RS3-style **money pouch** for protected coin storage:

### Architecture

* **Money Pouch** (`characters.coins`): Protected storage, doesn't use inventory slots
* **Physical Coins** (`inventory` with `itemId='coins'`): Stackable item, uses inventory slot

### Coin Pouch Withdrawal

Players can withdraw coins from the money pouch to inventory:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Client: Click coin pouch to open modal
<CoinPouch coins={coins} onWithdrawClick={openCoinModal} />

// Server: Handle withdrawal request
async function handleCoinPouchWithdraw(
  socket: ServerSocket,
  data: { amount: number; timestamp: number },
  world: World,
): Promise<void> {
  // 1. Rate limit check (10/sec)
  if (!getCoinPouchRateLimiter().check(playerId)) return;
  
  // 2. Timestamp validation (replay attack protection)
  const timestampResult = validateRequestTimestamp(data.timestamp);
  if (!timestampResult.valid) return;
  
  // 3. Amount validation
  if (!isValidQuantity(data.amount)) return;
  
  // 4. Atomic database transaction
  await db.drizzle.transaction(async (tx) => {
    // Lock character row
    const charRow = await tx.execute(
      sql`SELECT coins FROM characters WHERE id = ${playerId} FOR UPDATE`
    );
    
    // Check sufficient balance
    if (charRow.coins < amount) throw new Error("INSUFFICIENT_COINS");
    
    // Add to existing coins stack or create new
    if (existingStack) {
      if (wouldOverflow(existingStack.quantity, amount)) {
        throw new Error("STACK_OVERFLOW");
      }
      await tx.execute(
        sql`UPDATE inventory SET quantity = quantity + ${amount}
            WHERE playerId = ${playerId} AND itemId = 'coins'`
      );
    } else {
      // Find empty slot and insert
      await tx.insert(schema.inventory).values({
        playerId, itemId: "coins", quantity: amount, slotIndex: emptySlot
      });
    }
    
    // Deduct from pouch
    await tx.execute(
      sql`UPDATE characters SET coins = coins - ${amount} WHERE id = ${playerId}`
    );
  });
  
  // 5. Sync in-memory systems
  world.emit(EventType.INVENTORY_UPDATE_COINS, { playerId, coins: newBalance });
  await inventorySystem.reloadFromDatabase(playerId);
}
```

### Security Features

| Feature                  | Implementation              | Purpose                      |
| ------------------------ | --------------------------- | ---------------------------- |
| **Rate Limiting**        | 10 requests/second          | Prevents spam attacks        |
| **Timestamp Validation** | ±30 second window           | Prevents replay attacks      |
| **Input Validation**     | Positive integers, max 2.1B | Prevents exploits            |
| **Overflow Protection**  | `wouldOverflow()` check     | Prevents MAX\_COINS overflow |
| **Row Locking**          | `FOR UPDATE` in transaction | Prevents race conditions     |
| **Audit Logging**        | Logs withdrawals ≥1M coins  | Security monitoring          |

### UI Components

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// CoinPouch component (extracted for reusability)
export function CoinPouch({ coins, onWithdrawClick }: CoinPouchProps) {
  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      onWithdrawClick();
    }
  }, [onWithdrawClick]);

  return (
    <div
      role="button"
      tabIndex={0}
      onClick={onWithdrawClick}
      onKeyDown={handleKeyDown}
      aria-label={`Money pouch: ${coins.toLocaleString()} coins. Press Enter to withdraw.`}
      title="Click to withdraw coins to inventory"
    >
      {/* Coin pouch display */}
    </div>
  );
}
```

**Accessibility Features:**

* `role="button"` for screen readers
* `tabIndex={0}` for keyboard focus
* Enter/Space key activation
* Descriptive `aria-label`

### Error Handling

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Error messages for withdrawal failures
const userMessages: Record<string, string> = {
  INSUFFICIENT_COINS: "Not enough coins in pouch",
  INVENTORY_FULL: "Your inventory is full",
  STACK_OVERFLOW: "Cannot stack that many coins",
  PLAYER_NOT_FOUND: "Character not found",
};

// Graceful sync failure handling
try {
  await inventorySystem.reloadFromDatabase(playerId);
  inventorySystem.emitInventoryUpdate(playerId);
} catch (syncError) {
  // Log but don't fail - transaction succeeded, player can relog to resync
  console.error(`Sync failed for player ${playerId}:`, syncError);
}
```

<Info>
  **Testing**: The coin pouch system has 32 comprehensive unit tests covering input validation, insufficient coins, inventory full, stack overflow, new stack creation, existing stack updates, and atomicity simulation.
</Info>

***

## Key Operations

### Adding Items

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// InventorySystem.addItem() flow:
// 1. Validate player exists and isn't in transaction
// 2. For stackable items, merge with existing stack
// 3. For non-stackable, find empty slot
// 4. Emit INVENTORY_UPDATED event
// 5. Schedule database persistence

async addItem(playerId: string, itemId: string, quantity: number): Promise<boolean> {
  const inventory = this.playerInventories.get(playerId);
  if (!inventory) return false;
  
  // Check transaction lock (bank, store operations block pickups)
  if (this.transactionLocks.has(playerId)) {
    console.warn(`[Inventory] Player ${playerId} locked - rejecting addItem`);
    return false;
  }
  
  const item = getItem(itemId);
  if (!item) return false;
  
  // Stackable: merge with existing
  if (item.stackable) {
    const existing = inventory.items.find(i => i.itemId === itemId);
    if (existing) {
      existing.quantity += quantity;
      this.emitUpdate(playerId);
      return true;
    }
  }
  
  // Find empty slot
  const emptySlot = this.findEmptySlot(inventory);
  if (emptySlot === -1) return false; // Inventory full
  
  inventory.items.push({
    id: generateUniqueId(),
    itemId,
    quantity,
    slot: emptySlot,
    metadata: null,
  });
  
  this.emitUpdate(playerId);
  return true;
}
```

### Dropping Items

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Drop item from inventory to ground
async dropItem(data: { playerId: string; slot: number; quantity: number }): Promise<void> {
  const inventory = this.getInventory(data.playerId);
  const item = inventory.items.find(i => i.slot === data.slot);
  if (!item) return;
  
  const dropQuantity = Math.min(data.quantity, item.quantity);
  const player = this.world.entities.get(data.playerId);
  
  // Spawn ground item at player position
  const groundItemSystem = this.world.getSystem<GroundItemSystem>('ground-items');
  await groundItemSystem.spawnGroundItem(
    item.itemId,
    dropQuantity,
    player.position,
    {
      droppedBy: data.playerId,
      despawnTime: 60000,  // 60 seconds
      lootProtection: 0,   // No protection for dropped items
    }
  );
  
  // Remove from inventory
  this.removeItem({
    playerId: data.playerId,
    itemId: item.itemId,
    quantity: dropQuantity,
  });
}
```

### Pickup Items

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Pickup ground item - with race condition protection
async pickupItem(data: { playerId: string; entityId: string }): Promise<void> {
  // Prevent double-pickup via locks
  if (this.pickupLocks.has(data.entityId)) return;
  this.pickupLocks.add(data.entityId);
  
  try {
    const groundItemSystem = this.world.getSystem<GroundItemSystem>('ground-items');
    const groundItem = groundItemSystem.getGroundItem(data.entityId);
    
    if (!groundItem) return;
    
    // Check loot protection
    if (!groundItemSystem.canPickup(data.entityId, data.playerId, this.world.currentTick)) {
      this.emitTypedEvent(EventType.UI_MESSAGE, {
        playerId: data.playerId,
        message: "You can't pick this up yet.",
        type: "error",
      });
      return;
    }
    
    // Try to add to inventory
    const success = await this.addItem(data.playerId, groundItem.itemId, groundItem.quantity);
    
    if (success) {
      groundItemSystem.removeGroundItem(data.entityId);
    } else {
      this.emitTypedEvent(EventType.UI_MESSAGE, {
        playerId: data.playerId,
        message: "Your inventory is full.",
        type: "error",
      });
    }
  } finally {
    this.pickupLocks.delete(data.entityId);
  }
}
```

***

## Transaction Locking

For atomic operations like bank deposits, store purchases, and trades, the inventory system supports transaction locks:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Lock prevents concurrent modifications
private transactionLocks = new Set<string>();

lockInventory(playerId: string): void {
  this.transactionLocks.add(playerId);
}

unlockInventory(playerId: string): void {
  this.transactionLocks.delete(playerId);
}

// While locked:
// - addItem() rejects new items
// - Auto-save skips this player
// - Only lock holder can modify
```

***

## Database Persistence

Inventories are persisted to the database via the `DatabaseSystem`:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Auto-save every 30 seconds
private startAutoSave(): void {
  this.saveInterval = setInterval(() => {
    this.performAutoSave();
  }, this.AUTO_SAVE_INTERVAL);
}

// Also saves on:
// - Player disconnect
// - Significant item changes
// - Server shutdown

async persistInventory(playerId: string): Promise<void> {
  const inventory = this.playerInventories.get(playerId);
  if (!inventory) return;
  
  const database = this.world.getSystem<DatabaseSystem>('database');
  await database.saveInventory(playerId, {
    items: inventory.items,
    coins: inventory.coins,
  });
}
```

***

## Item Consumption

### Food and Healing

Players can consume food items to restore health with OSRS-accurate mechanics:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From PlayerSystem.ts - handleItemUsed()
// Food consumption flow:
// 1. Client sends useItem packet
// 2. Server validates eat delay (3 ticks = 1.8s cooldown)
// 3. Server validates item exists at slot
// 4. Server consumes food and applies healing
// 5. Server adds attack delay if in combat

// Eat delay constants (from CombatConstants.ts)
const EAT_DELAY_TICKS = 3;              // 1.8 seconds between foods
const EAT_ATTACK_DELAY_TICKS = 3;       // Added to attack cooldown when eating mid-combat
const MAX_HEAL_AMOUNT = 99;             // Security cap on healing
```

<Info>
  **OSRS-Accurate Behavior**: Food is consumed even at full health, and the eat delay applies regardless. Attack delay is only added if the player is already on attack cooldown.
</Info>

### Eat Delay Manager

The `EatDelayManager` tracks per-player eating cooldowns:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From packages/shared/src/systems/shared/character/EatDelayManager.ts
class EatDelayManager {
  canEat(playerId: string, currentTick: number): boolean;
  recordEat(playerId: string, currentTick: number): void;
  getRemainingCooldown(playerId: string, currentTick: number): number;
  clearPlayer(playerId: string): void;
}

// Usage in PlayerSystem
if (!this.eatDelayManager.canEat(playerId, currentTick)) {
  // Show "You are already eating." message
  return;
}

this.eatDelayManager.recordEat(playerId, currentTick);
// Consume food and heal...
```

### Combat Integration

When eating during combat, attack delay is added to prevent instant attacks:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From PlayerSystem.ts - applyEatAttackDelay()
const isOnCooldown = combatSystem.isPlayerOnAttackCooldown(playerId, currentTick);

if (isOnCooldown) {
  // OSRS Rule: Only add delay if weapon is already on cooldown
  combatSystem.addAttackDelay(playerId, EAT_ATTACK_DELAY_TICKS);
}
// If weapon is ready, eating does NOT add delay
```

***

## Context Menus

Inventory items support OSRS-style context menus with manifest-driven actions.

### Manifest-Driven Actions

Items can define explicit `inventoryActions` in their manifest:

```json theme={"theme":{"light":"github-light","dark":"css-variables"}}
{
  "id": "shrimp",
  "name": "Shrimp",
  "type": "consumable",
  "healAmount": 3,
  "inventoryActions": ["Eat", "Use", "Drop", "Examine"]
}
```

The first action becomes the left-click default. If not specified, the system falls back to type-based detection.

### Action Types

| Action      | Trigger          | Description                                |
| ----------- | ---------------- | ------------------------------------------ |
| **Eat**     | Food items       | Consumes food, heals HP, applies eat delay |
| **Drink**   | Potions          | Consumes potion, applies effects           |
| **Wield**   | Weapons, shields | Equips to weapon/shield slot               |
| **Wear**    | Armor            | Equips to armor slot                       |
| **Bury**    | Bones            | Buries bones for Prayer XP                 |
| **Use**     | Tools, misc      | Enters targeting mode                      |
| **Drop**    | Any item         | Drops to ground                            |
| **Examine** | Any item         | Shows examine text                         |

### Item Helpers

The `item-helpers.ts` module provides type detection utilities for OSRS-accurate inventory actions:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
import { 
  isFood, 
  isPotion, 
  isBone, 
  isWeapon,
  isShield,
  usesWield, 
  usesWear, 
  isNotedItem,
  getPrimaryAction,
  getPrimaryActionFromManifest,
  HANDLED_INVENTORY_ACTIONS
} from '@hyperscape/shared';

// Type detection
isFood(item);        // Has healAmount, not a potion
isPotion(item);      // Contains "potion" in ID
isBone(item);        // ID is "bones" or ends with "_bones"
isWeapon(item);      // equipSlot is "weapon" or "2h", or has weaponType
isShield(item);      // equipSlot is "shield"
usesWield(item);     // Weapons and shields (uses "Wield" action)
usesWear(item);      // Armor (head, body, legs, etc.) (uses "Wear" action)
isNotedItem(item);   // Bank note (isNoted flag or "_noted" suffix)

// Get primary action (manifest-first with heuristic fallback)
const action = getPrimaryAction(item, isNoted);
// Returns: "eat" | "drink" | "bury" | "wield" | "wear" | "use"

// Get action from manifest only (no fallback)
const manifestAction = getPrimaryActionFromManifest(item);
// Returns: PrimaryActionType | null

// Check if action is handled
HANDLED_INVENTORY_ACTIONS.has("eat");  // true
// Set contains: eat, drink, bury, wield, wear, drop, examine, use
```

<Info>
  **Testing**: The item-helpers module has 510 lines of comprehensive unit tests covering all type detection functions and edge cases.
</Info>

### Context Menu Colors

Context menus use OSRS-accurate color coding with centralized constants:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
import { CONTEXT_MENU_COLORS } from '@hyperscape/shared';

CONTEXT_MENU_COLORS.ITEM;    // #ff9040 (orange) - Item names
CONTEXT_MENU_COLORS.NPC;     // #ffff00 (yellow) - NPC/mob names
CONTEXT_MENU_COLORS.OBJECT;  // #00ffff (cyan) - Scenery/objects
CONTEXT_MENU_COLORS.PLAYER;  // #ffffff (white) - Player names
```

**Example Usage:**

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Context menu with styled labels
{
  id: "eat",
  label: "Eat Shrimp",
  styledLabel: [
    { text: "Eat " },
    { text: "Shrimp", color: CONTEXT_MENU_COLORS.ITEM }
  ],
  enabled: true,
  priority: 1,
}
```

<Info>
  All interaction handlers (NPCInteractionHandler, MobInteractionHandler, ItemInteractionHandler, etc.) now use these centralized constants instead of hardcoded values.
</Info>

***

## Inventory Events

| Event                    | Data                                     | Description                      |
| ------------------------ | ---------------------------------------- | -------------------------------- |
| `INVENTORY_UPDATED`      | `playerId`, `items`, `coins`             | Inventory changed                |
| `INVENTORY_ITEM_ADDED`   | `playerId`, `item`                       | Item added                       |
| `INVENTORY_ITEM_REMOVED` | `playerId`, `itemId`, `quantity`         | Item removed                     |
| `INVENTORY_MOVE`         | `playerId`, `fromSlot`, `toSlot`         | Item slot changed                |
| `INVENTORY_USE`          | `playerId`, `itemId`, `slot`             | Item used (food, potions)        |
| `ITEM_USED`              | `playerId`, `itemId`, `slot`, `itemData` | Item consumed (after validation) |
| `ITEM_PICKUP`            | `playerId`, `entityId`, `itemId`         | Player picking up item           |
| `ITEM_DROP`              | `playerId`, `slot`, `quantity`           | Player dropping item             |

***

## UI Features

### Hover Tooltips

Inventory and equipment items show tooltips on hover:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From InventoryPanel.tsx and EquipmentPanel.tsx
const [hoveredItem, setHoveredItem] = useState<{ itemId: string; slot: number } | null>(null);

// Tooltip displays:
// - Item name
// - Item stats (if equipment)
// - Examine text
// - Level requirements (if applicable)

<div
  onMouseEnter={() => setHoveredItem({ itemId, slot })}
  onMouseLeave={() => setHoveredItem(null)}
>
  {/* Item display */}
</div>

{hoveredItem && (
  <Tooltip item={getItem(hoveredItem.itemId)} position={mousePos} />
)}
```

**Tooltip Features:**

* Follows mouse cursor
* Shows item stats and bonuses
* Displays level requirements
* Includes examine text
* Auto-hides on mouse leave

### Click-to-Unequip

Equipment slots support left-click to unequip (OSRS-style):

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From EquipmentPanel.tsx
const handleSlotClick = (slot: EquipmentSlot) => {
  const equippedItem = equipment[slot];
  if (!equippedItem) return;
  
  // Send unequip request to server
  world.network?.send("unequipItem", {
    playerId: localPlayer.id,
    slot,
  });
};

// Renders as clickable slot
<div
  onClick={() => handleSlotClick("helmet")}
  style={{ cursor: "pointer" }}
>
  {/* Equipment item display */}
</div>
```

**Unequip Behavior:**

* Left-click equipped item to unequip
* Item moves to first available inventory slot
* If inventory full, shows "Your inventory is full" message
* Right-click still shows context menu with "Remove" option

<Info>
  This matches OSRS behavior where left-clicking equipment unequips it directly.
</Info>

***

## UI Integration

The client displays the inventory via `InventoryPanel.tsx`:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Sidebar.tsx - subscribes to inventory updates
useEffect(() => {
  world.on(EventType.INVENTORY_UPDATED, (data) => {
    setInventory(data.items);
    setCoins(data.coins);
  });
}, [world]);
```

Features:

* 28-slot grid layout
* Drag-and-drop item movement
* **OSRS-style right-click context menus** with manifest-driven actions and colored labels
* **Left-click primary actions** (Eat, Wield, Use, etc.) using `getPrimaryAction()`
* **Shift-click to drop** (OSRS-style instant drop)
* **Invalid target feedback**: "Nothing interesting happens." when using item on invalid target
* Stack quantity display with OSRS-style formatting
* Coin pouch separate display
* **Cancel option** always shown last in context menus
* **Performance optimizations**: `useMemo` and `useCallback` for render efficiency

***

## Context Menus

### Manifest-Driven Actions

Items define their actions in `items.json`:

```json theme={"theme":{"light":"github-light","dark":"css-variables"}}
{
  "id": "cooked_shrimp",
  "name": "Cooked shrimp",
  "type": "consumable",
  "healAmount": 3,
  "inventoryActions": ["Eat", "Drop", "Examine"]
}
```

### Action Ordering

Actions are ordered by priority (first action is left-click default):

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From item-helpers.ts
export const ACTION_PRIORITY = {
  eat: 1,      // Food primary action
  drink: 1,    // Potion primary action
  wield: 1,    // Weapon/shield primary action
  wear: 1,     // Armor primary action
  bury: 1,     // Bones primary action
  use: 2,      // Generic use action
  drop: 9,     // Always near bottom
  examine: 10, // Always last (before Cancel)
  cancel: 11,  // Always absolute last
};
```

### InventoryActionDispatcher

The `InventoryActionDispatcher` is the **single source of truth** for handling inventory actions. It eliminates duplication between context menu selections and left-click primary actions:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From packages/client/src/game/systems/InventoryActionDispatcher.ts
export function dispatchInventoryAction(
  action: string,
  ctx: InventoryActionContext,
): ActionResult {
  const { world, itemId, slot, quantity = 1 } = ctx;
  const localPlayer = world.getPlayer();

  if (!localPlayer) {
    return { success: false, message: "No local player" };
  }

  switch (action) {
    case "eat":
    case "drink":
      // Server-authoritative consumption via useItem packet
      world.network?.send("useItem", { itemId, slot });
      return { success: true };

    case "bury":
      world.network?.send("buryBones", { itemId, slot });
      return { success: true };

    case "wield":
    case "wear":
      world.network?.send("equipItem", {
        playerId: localPlayer.id,
        itemId,
        inventorySlot: slot,
      });
      return { success: true };

    case "drop":
      world.network?.send("dropItem", { itemId, slot, quantity });
      return { success: true };

    case "examine":
      const examineText = itemData?.examine || `It's a ${itemId}.`;
      world.emit(EventType.UI_TOAST, { message: examineText, type: "info" });
      // Also add to chat (OSRS-style game message)
      world.chat?.add({
        id: uuid(),
        from: "",
        body: examineText,
        createdAt: new Date().toISOString(),
        timestamp: Date.now(),
      });
      return { success: true };

    case "use":
      // Enter targeting mode for "Use X on Y" interactions
      world.emit(EventType.ITEM_ACTION_SELECTED, {
        playerId: localPlayer.id,
        actionId: "use",
        itemId,
        slot,
      });
      return { success: true };

    case "cancel":
      // Intentional no-op - menu already closed by EntityContextMenu
      return { success: true };

    default:
      // Warn for unhandled actions (helps catch manifest typos)
      console.warn(`Unhandled action: "${action}" for item "${itemId}"`);
      return { success: false, message: `Unhandled action: ${action}` };
  }
}
```

<Info>
  **Testing**: The dispatcher has 333 lines of comprehensive unit tests covering all action types, error handling, and edge cases.
</Info>

***

## Equipment System Integration

The inventory system integrates with the equipment system for atomic equip/unequip operations that prevent item duplication.

### Atomic Equip Operation

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From packages/shared/src/systems/shared/character/EquipmentSystem.ts
async equipItem(playerId: string, itemId: string, inventorySlot: number): Promise<boolean> {
  // 1. Acquire transaction lock
  if (!this.acquireLock(playerId)) return false;
  
  try {
    // 2. Remove from inventory FIRST (prevents duplication)
    const removed = await inventorySystem.removeItemDirect(playerId, {
      itemId,
      slot: inventorySlot,
      quantity: 1,
    });
    
    if (!removed) {
      return false;  // Abort if removal fails
    }
    
    // 3. Equip to slot
    this.setEquipmentSlot(playerId, slot, itemId);
    
    return true;
  } finally {
    this.releaseLock(playerId);
  }
}
```

### Atomic Unequip Operation

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
async unequipItem(playerId: string, slot: EquipmentSlot): Promise<boolean> {
  // 1. Check inventory has space BEFORE unequipping
  if (!inventorySystem.hasSpace(playerId, 1)) {
    return false;
  }
  
  // 2. Acquire transaction lock
  if (!this.acquireLock(playerId)) return false;
  
  try {
    // 3. Clear equipment slot FIRST (prevents item loss)
    const itemId = this.getEquippedItem(playerId, slot);
    this.clearEquipmentSlot(playerId, slot);
    
    // 4. Add to inventory
    const added = await inventorySystem.addItemDirect(playerId, {
      itemId,
      quantity: 1,
    });
    
    if (!added) {
      // Rollback: restore equipment slot
      this.setEquipmentSlot(playerId, slot, itemId);
      return false;
    }
    
    return true;
  } finally {
    this.releaseLock(playerId);
  }
}
```

### New InventorySystem Helper Methods

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From packages/shared/src/systems/shared/character/InventorySystem.ts

/**
 * Check if inventory has space for items
 */
hasSpace(playerId: string, slotsNeeded: number): boolean;

/**
 * Verify item exists at specific slot
 */
hasItemAtSlot(playerId: string, itemId: string, slot: number): boolean;

/**
 * Synchronous removal with return value (for atomic operations)
 */
removeItemDirect(playerId: string, params: RemoveItemParams): boolean;

/**
 * Synchronous add with return value (for atomic operations)
 */
addItemDirect(playerId: string, params: AddItemParams): boolean;
```

**Key Safety Features:**

* **Transaction locks** prevent concurrent equip/unequip race conditions
* **Order of operations** prevents both duplication and item loss:
  * Equip: Remove from inventory FIRST, then equip
  * Unequip: Clear equipment FIRST, then add to inventory
* **Rollback on failure** restores state if operation fails
* **Inventory validation** checks space before unequipping

***

## Related Documentation

* [Equipment System](/wiki/game-systems/equipment) (equip/unequip mechanics)
* [Bank System](/wiki/game-systems/bank) (bank storage)
* [Trading System](/wiki/game-systems/trading) (player-to-player trading)
* [Ground Items & Loot](/wiki/game-systems/loot) (item drops)
* [Item Data Structure](/wiki/data/items) (item definitions)
* [Combat System](/wiki/game-systems/combat) (eat delay mechanics)
