Economy

Bank / Stash

A personal item-storage container — a role over the Container engine (opaque item blobs; atomic bag<->bank deposit/withdraw via TryExchange; policy SPI; persistence). Depends on Container, not Inventory.

Server
services.AddCrossplayBank();
Unity package
com.crossplay.bank
Depends on
container

Seams you implement 1

Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayBank(); and it wins.

IBankPolicy game SPI

SPI the game implements to give the bank its item rules — the framework only moves opaque bytes. The default is permissive: byte-equal items stack up to the configured max, anything can sit in any slot. Register your own (before AddCrossplayBank) for per-item stack limits, no-store / soulbound checks, or which slots accept which items. This mirrors IInventoryPolicy so a game can share one implementation across bag and bank if it wants.

sealed class BankRules : IBankPolicy
{
    public bool CanStack(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b) => a.SequenceEqual(b);
    public int MaxStackFor(ReadOnlySpan<byte> item) => 999;
    public bool CanPlace(int slot, ReadOnlySpan<byte> item) => !IsSoulbound(item); // bank refuses soulbound
}
// services.AddSingleton<IBankPolicy, BankRules>(); // BEFORE AddCrossplayBank()
  • bool CanPlace(int slot, ReadOnlySpan<byte> item)

    May this item occupy this bank slot?

  • bool CanStack(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b)

    Can these two item payloads share a stack?

  • int MaxStackFor(ReadOnlySpan<byte> item)

    Largest stack for this item.

Services you call 1

Crossplay implements these. Resolve them from DI and call them from your own systems.

IBankService

An owner's personal bank/stash: a role over the generic Container engine keyed bank:{ownerId}. Deposits and withdrawals are atomic moves between the bag (bag:{ownerId}) and the bank via IContainerService.TryExchange — the item leaves one container and enters the other in ONE transaction (both persist together). Genre-agnostic: items are opaque bytes. The Bank piece does not reference the Inventory piece; it only shares the bag container-id convention.

  • event Action<long> Changed

    Raised (with the owner id) after that owner's bank changes.

  • ushort Deposit(long ownerId, int fromBagSlot, int toBankSlot)

    Atomically move the stack at the owner's bag slot fromBagSlot into the owner's bank. toBankSlot < 0 auto-places; ≥ 0 is a best-effort placement hint. Returns 0 on success, else a BankNotificationCodes reason (nothing changes on failure).

  • IReadOnlyList<BankSlot> Get(long ownerId)

    The owner's bank contents (occupied slots only).

  • ushort Move(long ownerId, int fromSlot, int toSlot)

    Rearrange within the bank (move to empty / merge onto a match / swap). 0 on success.

  • ushort Withdraw(long ownerId, int fromBankSlot, int toBagSlot)

    Atomically move the stack at the owner's bank slot fromBankSlot into the owner's bag. toBagSlot < 0 auto-places; ≥ 0 is a best-effort placement hint. Returns 0 on success, else a BankNotificationCodes reason (nothing changes on failure).

Configuration 1

Every tunable lives in an options object — there are no magic numbers to hunt for.

BankOptions

Configurable bank parameters (defaults here; never inlined in logic — HARD RULE 4). A personal bank/stash is typically larger than the carried bag.

  • int MaxItemBytes { get; set; }

    Largest opaque item payload (bytes) accepted.

  • int MaxStack { get; set; }

    Default max stack size (the policy can vary it per item).

  • int SlotCount { get; set; }

    Slots in an owner's bank.

Wire messages 8

The protocol this piece speaks. Ids are allocated per piece so they can never collide.

4601 BankRequest

Client → server: send me my bank.

4602 BankContents

Server → client: the owner's full bank (occupied slots only). Pushed in reply to BankRequest and after every change (deposit/withdraw/move).

4603 BankDepositRequest

Client → server: move the stack at bag slot FromBagSlot into my bank. The custody transfer (bag → bank) is atomic; ToBankSlot is an optional placement hint (default -1 = let the engine auto-place; rearrange later via BankMoveRequest).

4604 BankDepositResponse

Server → client: result of a deposit (fresh contents arrive separately on success).

4605 BankWithdrawRequest

Client → server: move the stack at bank slot FromBankSlot back into my bag. The custody transfer (bank → bag) is atomic; ToBagSlot is an optional placement hint (default -1 = auto-place).

4606 BankWithdrawResponse

Server → client: result of a withdraw (fresh contents arrive separately on success).

4607 BankMoveRequest

Client → server: rearrange within the bank — plain move to an empty slot, merge onto a stackable match, swap otherwise (the same semantics as an inventory move, but within the bank).

4608 BankMoveResponse

Server → client: result of an in-bank move (the new contents arrive separately on success).

Unity services you inject 1

Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.

IBankClient

The Bank piece, client side: a personal (per-owner) stash of opaque item stacks — the storage sibling of Crossplay.Inventory. Request your bank, deposit a stack out of your bag, withdraw a stack back into your bag, and rearrange within the bank. The server pushes a fresh BankContents snapshot in reply to a request and after every change (deposit / withdraw / move), so an open view stays live. Item bytes are your game's vocabulary — map them onto your own icons/tooltips/UI. Remove this package and banks simply stop; nothing else breaks.

  • BankContents Contents

    The latest contents snapshot pushed by the server (null until first received).

  • event Action<BankContents> ContentsChanged

    A fresh snapshot arrived (reply to a request, or a re-push after a change).

  • UniTask<BankContents> RequestAsync(CancellationToken ct = default)

    Requests the current bank contents (the reply raises ContentsChanged).

  • UniTask<BankDepositResponse> DepositAsync(int fromBagSlot, int toBankSlot = -1, CancellationToken ct = default)

    Deposits the stack at bag slot fromBagSlot into the bank. The custody transfer (bag → bank) is atomic; toBankSlot is an optional placement hint (default -1 = let the server auto-place). Fresh contents arrive separately on success.

  • UniTask<BankWithdrawResponse> WithdrawAsync(int fromBankSlot, int toBagSlot = -1, CancellationToken ct = default)

    Withdraws the stack at bank slot fromBankSlot back into the bag. The custody transfer (bank → bag) is atomic; toBagSlot is an optional placement hint (default -1 = auto-place). Fresh contents arrive separately on success.

  • UniTask<BankMoveResponse> MoveAsync(int fromSlot, int toSlot, CancellationToken ct = default)

    Rearranges within the bank — move to an empty slot, merge onto a stackable match, or swap (the inventory-move semantics, but within the bank). Fresh contents arrive separately on success.

UI views you can replace 1

Each ships a working uGUI panel you can drag into a scene. Substitute your own view to keep the logic and change the look — the presenter never knows.

ICrossplayBankView

Narrow contract the presenter drives. UI-only: render calls in, user intents out. Holds no domain data, no services, no networking (HARD RULE 6).