> ## 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.

# Bank System

> Per-town bank storage with tabs and placeholders

# Bank System

The banking system provides persistent item storage separate from the player's inventory. Following OSRS/RS3 design, each town has its own bank and players can organize items into tabs.

<Info>
  Banking code lives in `packages/shared/src/systems/shared/economy/BankingSystem.ts`.
</Info>

## Core Features

* **Per-town banks** - Each starter town has its own bank
* **Independent storage** - Banks don't share items across towns
* **480 slot capacity** - Per bank (configurable via `BANKING_CONSTANTS.MAX_BANK_SLOTS`)
* **Tab organization** - Organize items into custom tabs (9 tabs: 1 main + 8 custom)
* **Placeholders** - RS3-style placeholder mode for remembered slots
* **Two-phase slot updates** - Prevents database constraint violations during bulk operations
* **Distance check** - Must be within 3 meters of bank booth
* **Combat interruption** - Bank automatically closes when player is attacked (OSRS-accurate)

<Warning>
  **Combat Closes Bank**: Being attacked automatically closes the bank interface (OSRS-accurate). Even splash attacks (0 damage) will interrupt banking. This is server-authoritative - the server sends a `bankClose` packet with `reason: "combat"`.
</Warning>

***

## Bank Constants

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From BankingConstants.ts
export const BANKING_CONSTANTS = {
  MAX_BANK_SLOTS: 480,        // Slots per bank
  INTERACTION_RANGE: 3,       // Max distance to use bank (meters)
  MAX_TABS: 9,                // Tab count (1 main + 8 custom)
  PLACEHOLDER_ITEM_ID: "placeholder", // Special item ID for placeholders
};

// Starter town banks
private readonly STARTER_TOWN_BANKS = [
  { id: "bank_town_0", name: "Central Bank", position: { x: 0, y: 0, z: 5 } },
  { id: "bank_town_1", name: "Eastern Bank", position: { x: 100, y: 0, z: 5 } },
  { id: "bank_town_2", name: "Western Bank", position: { x: -100, y: 0, z: 5 } },
  { id: "bank_town_3", name: "Northern Bank", position: { x: 0, y: 0, z: 105 } },
  { id: "bank_town_4", name: "Southern Bank", position: { x: 0, y: 0, z: -95 } },
];
```

***

## Bank Data Structure

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
interface BankData {
  items: BankItem[];       // Items stored in bank
  maxSlots: number;        // 480 per bank
}

interface BankItem {
  id: string;              // Item definition ID
  name: string;            // Item name
  quantity: number;        // Stack quantity
  stackable: boolean;      // Can items stack
}

// Player bank tracking
private playerBanks = new Map<PlayerID, Map<BankID, BankData>>();
private openBanks = new Map<PlayerID, BankID>(); // Currently open bank
```

***

## Bank Operations

### Opening a Bank

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
private openBank(data: { playerId: string; bankId: string; playerPosition?: Position3D }): void {
  const playerId = createPlayerID(data.playerId);
  const bankId = createBankID(data.bankId);
  
  // Check if already open (prevent recursive calls)
  if (this.openBanks.get(playerId) === bankId) return;
  
  // Distance check
  const bankInfo = this.STARTER_TOWN_BANKS.find(b => b.id === data.bankId);
  if (bankInfo && data.playerPosition) {
    const distance = calculateDistance(data.playerPosition, bankInfo.position);
    if (distance > 3) {
      this.emitTypedEvent(EventType.UI_MESSAGE, {
        playerId: data.playerId,
        message: "You need to be closer to the bank to use it.",
        type: "error",
      });
      return;
    }
  }
  
  // Track open bank
  this.openBanks.set(playerId, bankId);
  
  // Send bank data to client
  this.emitTypedEvent(EventType.UI_UPDATE, {
    playerId: data.playerId,
    component: "bank",
    data: {
      bankId: data.bankId,
      bankName: bankInfo?.name || "Bank",
      items: bank.items,
      maxSlots: bank.maxSlots,
      usedSlots: bank.items.length,
      isOpen: true,
    },
  });
}
```

### Depositing Items

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
private depositItem(data: BankDepositEvent): void {
  const playerId = createPlayerID(data.playerId);
  const bankId = this.openBanks.get(playerId);
  
  if (!bankId) {
    console.warn("[BankingSystem] No bank open for player:", playerId);
    return;
  }
  
  const bank = this.playerBanks.get(playerId)?.get(bankId);
  if (!bank) return;
  
  // Check if inventory has the item
  this.emitTypedEvent(EventType.INVENTORY_CHECK, {
    playerId: data.playerId,
    itemId: data.itemId,
    quantity: data.quantity,
    callback: (hasItem, itemInfo) => {
      if (!hasItem || !itemInfo) {
        this.emitMessage(data.playerId, "Item not found in inventory.", "error");
        return;
      }
      
      // Remove from inventory
      this.emitTypedEvent(EventType.INVENTORY_ITEM_REMOVED, {
        playerId: data.playerId,
        itemId: data.itemId,
        quantity: data.quantity,
      });
      
      // Check for existing stack
      const existingItem = bank.items.find(item => item.id === data.itemId);
      if (existingItem) {
        existingItem.quantity += data.quantity;
      } else {
        // Check bank capacity
        if (bank.items.length >= bank.maxSlots) {
          this.emitMessage(data.playerId, "Bank is full.", "error");
          // Refund item
          this.emitTypedEvent(EventType.INVENTORY_ITEM_ADDED, { ... });
          return;
        }
        
        bank.items.push({
          id: data.itemId,
          name: itemInfo.name,
          quantity: data.quantity,
          stackable: itemInfo.stackable,
        });
      }
      
      this.emitTypedEvent(EventType.BANK_DEPOSIT_SUCCESS, { ... });
      this.updateBankInterface(data.playerId, bankId);
    },
  });
}
```

### Withdrawing Items

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
private withdrawItem(data: BankWithdrawEvent): void {
  const playerId = createPlayerID(data.playerId);
  const bankId = this.openBanks.get(playerId);
  const bank = this.playerBanks.get(playerId)?.get(bankId);
  
  // Find item in bank
  const bankItemIndex = bank.items.findIndex(item => item.id === data.itemId);
  if (bankItemIndex === -1) {
    this.emitMessage(data.playerId, "Item not found in bank.", "error");
    return;
  }
  
  const bankItem = bank.items[bankItemIndex];
  if (bankItem.quantity < data.quantity) {
    this.emitMessage(data.playerId, "Not enough of that item in bank.", "error");
    return;
  }
  
  // Remove from bank
  bankItem.quantity -= data.quantity;
  if (bankItem.quantity <= 0) {
    bank.items.splice(bankItemIndex, 1);
  }
  
  // Add to inventory
  this.emitTypedEvent(EventType.INVENTORY_ITEM_ADDED, {
    playerId: data.playerId,
    item: {
      id: generateUniqueId(),
      itemId: bankItem.id,
      quantity: data.quantity,
      slot: -1, // Find empty slot
      metadata: null,
    },
  });
  
  this.updateBankInterface(data.playerId, bankId);
}
```

### Deposit All

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
private depositAllItems(data: { playerId: string; bankId: string }): void {
  const inventory = this.playerInventories.get(playerId);
  const bank = this.playerBanks.get(playerId)?.get(bankId);
  
  let itemsDeposited = 0;
  
  for (const item of inventory.items) {
    if (bank.items.length >= this.MAX_BANK_SLOTS) {
      this.emitMessage(playerId, `Bank is full! Deposited ${itemsDeposited} items.`, "warning");
      break;
    }
    
    bank.items.push({
      id: item.itemId,
      name: "",
      quantity: item.quantity,
      stackable: true,
    });
    
    this.emitTypedEvent(EventType.INVENTORY_ITEM_REMOVED, { ... });
    itemsDeposited++;
  }
  
  if (itemsDeposited > 0) {
    this.emitMessage(playerId, `Deposited ${itemsDeposited} items into the bank.`, "success");
  }
}
```

***

## Placeholder System

Banks support RS3-style placeholders that remember item positions:

### Release All Placeholders

The "Release All Placeholders" operation uses a **two-phase slot update** to prevent unique constraint violations:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From placeholders.ts
for (const tabIndex of affectedTabs) {
  // Phase 1: Add large offset to move all slots far from target range
  await tx.execute(
    sql`UPDATE bank_storage
        SET slot = slot + ${SLOT_OFFSET_TEMP}
        WHERE "playerId" = ${ctx.playerId} AND "tabIndex" = ${tabIndex}`,
  );

  // Phase 2: Renumber slots sequentially using ROW_NUMBER
  // Source slots are now ≥1000, target slots are 0-N, no overlap possible
  await tx.execute(
    sql`UPDATE bank_storage
        SET slot = subq.new_slot
        FROM (
          SELECT id, ROW_NUMBER() OVER (ORDER BY slot) - 1 as new_slot
          FROM bank_storage
          WHERE "playerId" = ${ctx.playerId} AND "tabIndex" = ${tabIndex}
        ) as subq
        WHERE bank_storage.id = subq.id`,
  );
}
```

**Why Two-Phase?**

PostgreSQL doesn't guarantee UPDATE execution order. Direct renumbering could cause collisions:

* Row A updates from slot 10→6
* Row B updates from slot 6→3
* If Row A executes first, both rows temporarily occupy slot 6 → **unique constraint violation**

The two-phase approach eliminates this by ensuring source and target slot ranges never overlap:

* Phase 1: All slots move to 1000+ range
* Phase 2: All slots renumber to 0-N range
* No collision possible since 1000+ and 0-N don't overlap

<Info>
  The same two-phase pattern is used in `compactBankSlots()` for consistent slot renumbering.
</Info>

***

## Bank Events

| Event                  | Data                                       | Description              |
| ---------------------- | ------------------------------------------ | ------------------------ |
| `BANK_OPEN`            | `playerId`, `bankId`, `playerPosition`     | Player opened bank       |
| `BANK_CLOSE`           | `playerId`, `bankId`                       | Player closed bank       |
| `BANK_DEPOSIT`         | `playerId`, `itemId`, `quantity`           | Depositing item          |
| `BANK_WITHDRAW`        | `playerId`, `itemId`, `quantity`           | Withdrawing item         |
| `BANK_DEPOSIT_ALL`     | `playerId`, `bankId`                       | Deposit entire inventory |
| `BANK_DEPOSIT_SUCCESS` | `playerId`, `itemId`, `quantity`, `bankId` | Deposit confirmed        |

***

## Placeholder System

Placeholders remember item positions when items are withdrawn, maintaining bank organization.

### Placeholder Operations

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Toggle placeholder mode (from BankPanel UI)
world.network.send("bankTogglePlaceholder", {
  playerId: player.id,
  enabled: true,  // Always set placeholders when withdrawing
});

// Release all placeholders (clear remembered positions)
world.network.send("bankReleaseAllPlaceholders", {
  playerId: player.id,
});
```

### Two-Phase Slot Update

When releasing all placeholders, the system uses a two-phase approach to prevent database unique constraint violations:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From packages/server/src/systems/ServerNetwork/handlers/bank/placeholders.ts

// Phase 1: Add large offset to move all slots far from target range
await tx.execute(
  sql`UPDATE bank_storage
      SET slot = slot + ${SLOT_OFFSET_TEMP}
      WHERE "playerId" = ${playerId} AND "tabIndex" = ${tabIndex}`
);

// Phase 2: Renumber slots sequentially using ROW_NUMBER
// Source slots are now ≥1000, target slots are 0-N, no overlap possible
await tx.execute(
  sql`UPDATE bank_storage
      SET slot = subq.new_slot
      FROM (
        SELECT id, ROW_NUMBER() OVER (ORDER BY slot) - 1 as new_slot
        FROM bank_storage
        WHERE "playerId" = ${playerId} AND "tabIndex" = ${tabIndex}
      ) as subq
      WHERE bank_storage.id = subq.id`
);
```

<Info>
  **Why Two-Phase?** PostgreSQL doesn't guarantee UPDATE execution order. Direct renumbering could cause temporary slot collisions (e.g., row A updates slot 10→6 before row B updates slot 6→3). The two-phase approach eliminates this by ensuring source and target ranges never overlap.
</Info>

***

## Database Persistence

Banks are persisted via `BankRepository`:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Database tables
// - bank_storage: Stores bank items (playerId, bankId, itemId, quantity, slot, tabIndex)
//   - Unique constraint: (playerId, tabIndex, slot)
// - bank_tabs: Stores tab configuration (playerId, tabIndex, iconItemId)

// Loaded on player login, saved on bank operations
// Server handles persistence via network packets with transaction safety
```

***

## Client UI

The `BankPanel.tsx` component displays:

* Grid view of all bank items
* Tab navigation (9 tabs)
* Search/filter functionality
* Deposit all button
* Withdraw-X functionality
* Placeholder toggle

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// BankPanel state
const [bankData, setBankData] = useState<{
  visible: boolean;
  items: Array<{ itemId: string; quantity: number; slot: number; tabIndex: number }>;
  tabs: Array<{ tabIndex: number; iconItemId: string | null }>;
  alwaysSetPlaceholder: boolean;
  maxSlots: number;
  bankId: string;
} | null>(null);
```

***

***

## Placeholder System

### Two-Phase Slot Updates

The bank system uses a **two-phase update** to prevent unique constraint violations when releasing all placeholders:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From bank/placeholders.ts
export async function releaseAllPlaceholders(
  playerId: string,
  bankId: string,
  repository: BankRepository,
): Promise<void> {
  // PHASE 1: Set all placeholders to temporary slot -1
  await repository.updatePlaceholdersToTempSlot(playerId, bankId);
  
  // PHASE 2: Delete all items with slot -1
  await repository.deleteTempSlotItems(playerId, bankId);
}
```

This prevents the error:

```
duplicate key value violates unique constraint "bank_storage_player_id_bank_id_slot_unique"
```

### Placeholder Toggle

Players can toggle placeholder mode:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// When enabled, withdrawing last item leaves a placeholder
if (alwaysSetPlaceholder && bankItem.quantity === withdrawQuantity) {
  // Replace with placeholder instead of deleting
  bankItem.itemId = BANKING_CONSTANTS.PLACEHOLDER_ITEM_ID;
  bankItem.quantity = 0;
  bankItem.isPlaceholder = true;
}
```

***

## Related Documentation

* [Inventory System](/wiki/game-systems/inventory)
* [Economy Overview](/wiki/game-systems/economy)
* [Database Schema](/wiki/engine/database)
* [Bank Repository](/api-reference/repositories#bankrepository)
