Skip to main content

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.
Inventory code lives in packages/shared/src/systems/shared/character/InventorySystem.ts and packages/client/src/game/systems/InventoryActionDispatcher.ts.

Core Constants

// 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:
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:
// 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

FeatureImplementationPurpose
Rate Limiting10 requests/secondPrevents spam attacks
Timestamp Validation±30 second windowPrevents replay attacks
Input ValidationPositive integers, max 2.1BPrevents exploits
Overflow ProtectionwouldOverflow() checkPrevents MAX_COINS overflow
Row LockingFOR UPDATE in transactionPrevents race conditions
Audit LoggingLogs withdrawals ≥1M coinsSecurity monitoring

UI Components

// 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

// 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);
}
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.

Key Operations

Adding Items

// 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

// 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

Performance Update (PR #888): Item and coin pickup is now visually instant. The ground entity is removed before the database write, eliminating the 500ms+ delay players previously experienced.
// Pickup ground item - with instant visual feedback
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;
    }
    
    // RESPONSIVE PICKUP: Add to memory first (silent = skip DB persist + emit),
    // remove ground entity and emit inventory update immediately so the player
    // sees instant feedback, THEN await the DB persist (still guaranteed, just
    // doesn't block the visible actions). Pickup lock is held throughout.
    const added = await this.addItem({
      playerId: data.playerId,
      itemId: groundItem.itemId,
      quantity: groundItem.quantity,
      silent: true,  // We'll emit update + persist ourselves after world removal
    });
    
    if (added) {
      // Remove ground entity IMMEDIATELY (before DB persist)
      const removed = groundItemSystem.removeGroundItem(data.entityId);
      
      if (!removed) {
        // Rollback: remove from inventory if ground entity removal failed
        await this.removeItem({
          playerId: data.playerId,
          itemId: groundItem.itemId,
          quantity: groundItem.quantity,
        });
        this.emitTypedEvent(EventType.UI_MESSAGE, {
          playerId: data.playerId,
          message: "Failed to pick up item. Please try again.",
          type: "warning",
        });
      } else {
        // Success: emit inventory update to client IMMEDIATELY (no DB wait)
        const playerIdKey = toPlayerID(data.playerId);
        if (playerIdKey) {
          this.emitInventoryUpdate(playerIdKey);
        }
        
        // Await DB persist — item is already visible to client and ground entity
        // is destroyed, but we still guarantee persistence before releasing the
        // pickup lock to prevent any race conditions.
        if (itemId === "coins") {
          const coinSystem = this.getCoinPouchSystem();
          if (coinSystem) {
            await coinSystem.persistCoinsImmediate(data.playerId);
          }
        } else {
          await this.persistInventoryImmediate(data.playerId);
        }
      }
    } else {
      this.emitTypedEvent(EventType.UI_MESSAGE, {
        playerId: data.playerId,
        message: "Your inventory is full.",
        type: "error",
      });
    }
  } finally {
    this.pickupLocks.delete(data.entityId);
  }
}
Key Changes:
  • addItem() now accepts silent flag to skip DB persist and event emission
  • Ground entity removed before database write (instant visual feedback)
  • Inventory update emitted immediately after ground entity removal
  • Database persist happens afterward (still guaranteed before lock release)
  • No item loss or duplication risk (pickup lock held throughout)
  • Coin pickup uses same pattern with skipPersist flag on addCoins()
Performance Impact:
  • Before: 500ms+ database round-trip blocked visual feedback
  • After: Ground item vanishes instantly, DB persist happens in background
  • Security: DB persist still awaited (not fire-and-forget)

Transaction Locking

For atomic operations like bank deposits, store purchases, and trades, the inventory system supports transaction locks:
// 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:
// 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:
// 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
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.

Eat Delay Manager

The EatDelayManager tracks per-player eating cooldowns:
// 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:
// 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:
{
  "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

ActionTriggerDescription
EatFood itemsConsumes food, heals HP, applies eat delay
DrinkPotionsConsumes potion, applies effects
WieldWeapons, shieldsEquips to weapon/shield slot
WearArmorEquips to armor slot
BuryBonesBuries bones for Prayer XP
UseTools, miscEnters targeting mode
DropAny itemDrops to ground
ExamineAny itemShows examine text

Item Helpers

The item-helpers.ts module provides type detection utilities for OSRS-accurate inventory actions:
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
Testing: The item-helpers module has 510 lines of comprehensive unit tests covering all type detection functions and edge cases.

Context Menu Colors

Context menus use OSRS-accurate color coding with centralized constants:
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:
// Context menu with styled labels
{
  id: "eat",
  label: "Eat Shrimp",
  styledLabel: [
    { text: "Eat " },
    { text: "Shrimp", color: CONTEXT_MENU_COLORS.ITEM }
  ],
  enabled: true,
  priority: 1,
}
All interaction handlers (NPCInteractionHandler, MobInteractionHandler, ItemInteractionHandler, etc.) now use these centralized constants instead of hardcoded values.

Inventory Events

EventDataDescription
INVENTORY_UPDATEDplayerId, items, coinsInventory changed
INVENTORY_ITEM_ADDEDplayerId, itemItem added
INVENTORY_ITEM_REMOVEDplayerId, itemId, quantityItem removed
INVENTORY_MOVEplayerId, fromSlot, toSlotItem slot changed
INVENTORY_USEplayerId, itemId, slotItem used (food, potions)
ITEM_USEDplayerId, itemId, slot, itemDataItem consumed (after validation)
ITEM_PICKUPplayerId, entityId, itemIdPlayer picking up item
ITEM_DROPplayerId, slot, quantityPlayer dropping item

UI Features

Hover Tooltips

Inventory and equipment items show tooltips on hover:
// 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):
// 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
This matches OSRS behavior where left-clicking equipment unequips it directly.

UI Integration

The client displays the inventory via InventoryPanel.tsx:
// 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:
{
  "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):
// 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:
// 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}` };
  }
}
Testing: The dispatcher has 333 lines of comprehensive unit tests covering all action types, error handling, and edge cases.


Equipment Interaction Optimizations

Stackable Equipment Merge (PR #887)

When equipping stackable items (like arrows) that match what’s already equipped, the system now merges quantities directly instead of unequipping and re-equipping:
// From EquipmentSystem.ts
// Same-type stackable merge: if equipping the same stackable item that's
// already in the slot (e.g. adding arrows to an existing arrow stack),
// just merge the quantity directly. Avoids the unequip→inventory flash→re-equip cycle.
const isSameStackableMerge =
  itemData.stackable &&
  equipmentSlot.itemId !== null &&
  String(equipmentSlot.itemId) === String(data.itemId);

if (!isSameStackableMerge) {
  // Different item or empty slot — unequip current item first if any
  if (equipmentSlot.itemId) {
    await this.unequipItem({
      playerId: data.playerId,
      slot: data.slot,
    });
  }
}

// Later in the function...
if (isSameStackableMerge) {
  // Merge quantity into already-equipped stack
  equipmentSlot.quantity = (equipmentSlot.quantity ?? 0) + quantityToEquip;
} else {
  // Now safe to equip - item has been removed from inventory
  equipmentSlot.itemId = data.itemId;
  equipmentSlot.item = itemData;
  equipmentSlot.quantity = quantityToEquip;
}
Benefits:
  • Eliminates brief inventory flash showing combined arrow count before equip
  • Reduces network packets and database writes
  • Improves responsiveness when equipping arrows
  • Maintains transaction safety (lock still held throughout)
Example Scenario:
  1. Player has 50 bronze arrows equipped
  2. Player equips 100 bronze arrows from inventory
  3. Old behavior: Unequip 50 → inventory shows 150 → equip 150 (visible flash)
  4. New behavior: Directly merge to 150 equipped (no flash)