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.
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> ChangedRaised (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.
BankRequest Client → server: send me my bank.
BankContents Server → client: the owner's full bank (occupied slots only). Pushed in reply to BankRequest and after every change (deposit/withdraw/move).
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).
BankDepositResponse Server → client: result of a deposit (fresh contents arrive separately on success).
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).
BankWithdrawResponse Server → client: result of a withdraw (fresh contents arrive separately on success).
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).
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 ContentsThe latest contents snapshot pushed by the server (null until first received).
-
event Action<BankContents> ContentsChangedA 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).