Economy

Guild Vault

Shared guild inventory of opaque item blobs with per-rank deposit/withdraw/view permissions and an audit trail.

Server
services.AddCrossplayGuildVault();
Unity package
com.crossplay.guildvault
Depends on
guild

Seams you implement 1

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

IGuildVaultPolicy game SPI

SPI the game implements to give the vault its item rules — the framework only moves opaque bytes. The default is permissive: byte-equal items stack up to the configured max, any deposit/withdraw the rank gate allowed is accepted. Register your own (before AddCrossplayGuildVault) for soulbound/no-deposit checks, per-item stack limits, or guild-tax rules. Rank permission is enforced separately (by GuildVaultOptions + guild rank) — this SPI decides ITEM validity only.

public sealed class MyVaultPolicy : IGuildVaultPolicy
{
    public bool CanStack(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b) => a.SequenceEqual(b);
    public int  MaxStackFor(ReadOnlySpan<byte> item) => 99;
    public bool CanDeposit(long guildId, long actorAccountId, ReadOnlySpan<byte> item, int count)
        => !IsSoulbound(item);            // keep soulbound items out of the shared vault
    public bool CanWithdraw(long guildId, long actorAccountId, ReadOnlySpan<byte> item, int count) => true;
}
  • bool CanDeposit(long guildId, long actorAccountId, ReadOnlySpan<byte> item, int count)

    May this account deposit this item stack into this guild's vault?

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

    Can these two item payloads share a stack?

  • bool CanWithdraw(long guildId, long actorAccountId, ReadOnlySpan<byte> item, int count)

    May this account withdraw this item stack from this guild's vault?

  • int MaxStackFor(ReadOnlySpan<byte> item)

    Largest stack for this item.

Providers you can swap 1

Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.

IGuildVaultStore provider

Persistence seam for guild vaults, keyed by guild id. In-memory by default; the persistence package overrides with a document-backed store so vaults (and their audit trail) survive restarts and are shared across cluster nodes.

  • void AppendAudit(VaultAuditEntry entry, int keep)

    Appends an audit entry, retaining at most keep most-recent per guild. The entry's VALUES are copied — the caller may reuse the passed instance.

  • IReadOnlyList<VaultAuditEntry> ReadAudit(long guildId)

    The retained audit entries for a guild, oldest first (fresh copies).

  • void Save(long guildId, IReadOnlyList<StoredVaultSlot> slots)

    Saves (replaces) a guild's stacks. The passed list is snapshotted, not retained — the caller may reuse it after the call (the service passes a reused scratch buffer, rule 11).

  • bool TryLoad(long guildId, out IReadOnlyList<StoredVaultSlot> slots)

    Loads a guild's stacks; false when none were ever saved.

Services you call 1

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

IGuildVaultService

Server-authoritative guild vaults, keyed by guild id: a shared inventory of opaque item blobs. Deposit/withdraw mechanics + item policy + persistence + an audit trail live here; guild-rank PERMISSION gating lives in the handler (it has the session's account + membership). Every mutation is atomic — planned against a scratch copy under a lock and committed whole — so concurrent withdraws of the same slot can never double-spend. Items are opaque bytes: the vault never interprets them and never references the Inventory piece.

  • IReadOnlyList<VaultAuditEntry> Audit(long guildId)

    The retained audit trail for a guild (oldest first).

  • event Action<VaultChange> Changed

    Raised after any successful mutation, so the handler can push VaultChanged.

  • ushort Deposit(long guildId, long actorAccountId, byte[] item, int count)

    Deposits count of an opaque item into the guild's vault: fills matching stacks first, then empty slots. All-or-nothing. Returns 0 on success or a GuildVaultNotificationCodes reason (item too large / vault full / policy refused).

  • IReadOnlyList<VaultSlot> List(long guildId)

    The occupied slots (wire-shaped; do not mutate).

  • ushort Withdraw(long guildId, long actorAccountId, int slot, int count, out byte[] withdrawnItem, out int withdrawnCount)

    Withdraws count from vault slot slot. Atomic: a concurrent second withdraw of the same slot fails rather than double-spending. Returns 0 on success (and the withdrawn payload + count) or a GuildVaultNotificationCodes reason.

Configuration 1

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

GuildVaultOptions

Configurable guild-vault parameters (defaults here; never inlined in logic — HARD RULE 4). The permission thresholds are guild ranks (GuildRank: Member 0 < Officer 1 < Leader 2); a rank at or above the threshold is allowed. The defaults model a classic guild bank: any member may deposit and view, but only officers and the leader may withdraw.

  • int AuditTrailSize { get; set; }

    How many recent audit entries to retain per guild (who deposited/withdrew what).

  • 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).

  • byte MinDepositRank { get; set; }

    Minimum rank allowed to deposit (default: any member).

  • byte MinViewRank { get; set; }

    Minimum rank allowed to view the vault (default: any member).

  • byte MinWithdrawRank { get; set; }

    Minimum rank allowed to withdraw (default: officers and above).

  • int SlotCount { get; set; }

    Slots in a guild's vault.

Wire messages 7

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

4201 VaultDepositRequest

Client → server: deposit Count of an opaque item into the guild vault. Moving the item out of the depositor's own inventory first is the GAME's policy — this piece only takes custody of the opaque bytes (it never references the Inventory piece).

4202 VaultDepositResponse

Server → client: result of a deposit (fresh contents arrive separately via the push).

4203 VaultWithdrawRequest

Client → server: withdraw Count from vault slot Slot.

4204 VaultWithdrawResponse

Server → client: result of a withdraw. On success it carries the withdrawn opaque payload + count so the GAME can route it (e.g. grant it to the withdrawing player's inventory).

4205 VaultListRequest

Client → server: send me my guild's vault contents.

4206 VaultContents

Server → client: the guild vault snapshot (occupied slots only). The Can* flags reflect the requester's rank permissions so a UI can gate its buttons — plain bools, no presentation baked in.

4207 VaultChanged

Server → client: a vault mutation happened — pushed to online guild members so open vault views refresh. Carries the actor + slot only (an "observers see the act" event, like GuildEvent); recipients re-request VaultListRequest for the new contents.

Unity services you inject 1

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

IGuildVaultClient

The GuildVault piece, client side: a guild-shared inventory of opaque item blobs. List your guild's contents, deposit an opaque stack, withdraw from a slot (the reply carries the withdrawn payload back so your game can route it — e.g. into the withdrawing player's inventory). The server pushes VaultChanged to every online guild member after any mutation, so an open view stays live when a guildmate moves an item. Item bytes are your game's vocabulary — the vault never interprets them. Remove this package and guild vaults simply stop; nothing else breaks.

  • VaultContents Contents

    The latest vault snapshot (null until the first ListAsync reply arrives).

  • event Action<VaultContents> ContentsChanged

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

  • event Action<VaultChanged> Changed

    A guildmate (or you) deposited/withdrew — carries actor + kind + slot for a feed/toast. The client automatically re-lists on this, so ContentsChanged follows shortly after.

  • UniTask<VaultContents> ListAsync(CancellationToken ct = default)

    Requests the guild's vault contents. The reply also raises ContentsChanged. If the caller isn't in a guild (or lacks the view rank) the server sends a notification instead of a snapshot, so this completes with the empty timeout value.

  • UniTask<VaultDepositResponse> DepositAsync(byte[] item, int count, CancellationToken ct = default)

    Deposits count of an opaque item into the guild vault. The wire deposit carries no slot — the server fills matching stacks first, then empty slots (all-or-nothing).

  • UniTask<VaultWithdrawResponse> WithdrawAsync(int slot, int count, CancellationToken ct = default)

    Withdraws count from vault slot slot. On success the response carries the withdrawn opaque payload + count (Item).

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.

ICrossplayGuildVaultView

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