Economy

Container

The generic opaque-item-container engine every item piece sits on: containerId-keyed slots/stacks, policy SPI, persistence, and atomic cross-container moves. Server-only; inventory/bank/vault/equipment are thin roles over it.

Server
services.AddCrossplayContainer();
Depends on
core
Shape
server-only · zero wire

Seams you implement 1

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

IContainerPolicy game SPI

SPI the game implements to give containers their 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. A container can carry its own policy (via Policy) so different container KINDS enforce different rules — a bag stacks freely while an equipment container only admits a weapon into slot 0. CanPlace receives the container id so a policy can vary placement by kind; stacking is an item property and stays id-free. Part of the abstract item SEAM (Crossplay.Container.Contracts) so a game supplies its own policy without referencing the engine. The permissive DefaultContainerPolicy implementation lives with the engine in Crossplay.Container.Server. Decode your own item header from the opaque bytes to answer each question; every call runs on the poll thread, so keep it allocation-free.

sealed class GameItemPolicy : IContainerPolicy
{
    public bool CanStack(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b) => a.SequenceEqual(b);
    public int MaxStackFor(ReadOnlySpan<byte> item) => IsWeapon(item) ? 1 : 99;
    public bool CanPlace(ContainerId c, int slot, ReadOnlySpan<byte> item) => true;
}
  • bool CanPlace(ContainerId container, int slot, ReadOnlySpan<byte> item)

    May this item occupy this slot of this container? Receives the container id so a policy can vary placement by kind (a bag stacks freely; an equip loadout admits only matching gear).

  • bool CanPlace(ContainerId container, int slot, ReadOnlySpan<byte> item, IContainerView view)

    Occupancy-aware placement: the same question as CanPlace but with a read-only IContainerView of the container's current (in-plan) contents, so rules that depend on OTHER slots — grid row-fit, adjacency, shaped containers — become expressible. The engine always calls THIS overload; the default implementation delegates to the classic one, so existing policies keep working unchanged.

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

    Can these two item payloads share a stack? (Typically byte-equality, but a game may stack items whose durability/roll differs by ignoring those header bytes.)

  • int FootprintFor(ContainerId container, int anchorSlot, ReadOnlySpan<byte> item, Span<int> cells)

    The cells this item CONSUMES when anchored at anchorSlot — the 2D-grid hook (Tarkov-style WxH items): decode the shape from your own item header, write the covered slot indices (including the anchor) into cells, and return the count. The engine blocks those cells against other placements and moves them as one. Only consulted for containers opened with Footprints enabled; the default is the classic single cell (cells[0] = anchorSlot; return 1;), so slot/stack containers are untouched. Row-wrap rules (a 2-wide item anchored in the last column) belong in the occupancy-aware CanPlace — the engine only range-checks cells against capacity.

  • int MaxStackFor(ReadOnlySpan<byte> item)

    Largest stack for this item (e.g. 1 for gear, 99 for materials).

Providers you can swap 2

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

IContainerConfigProvider provider

Optional seam: supplies the ContainerConfig to create a container with the FIRST time the engine loads it, when no explicit config was passed to EnsureContainer. A role piece registers one to claim its own id space's capacity/policy REGARDLESS of who touches the container first — so a sibling that resolves e.g. bag:{id} through the shared engine before the Inventory piece has ensured it still gets the bag's real slot count and rules, not the engine defaults. Providers are consulted in registration order; the first that owns the id wins. The engine stays genre-agnostic — it knows nothing about "bag"/"bank"; each piece owns its own id convention here.

  • bool TryGetConfig(ContainerId id, out ContainerConfig config)

    True when this provider owns id; yields the config to create it with.

IContainerStore provider

Persistence seam for containers, keyed by ContainerId. In-memory by default; a document-backed store rides the generic IDocumentStore so containers survive restarts and are shared across cluster nodes. The role pieces on top may bridge this seam onto their own legacy store (the Inventory piece routes bag containers through the classic per-character inventory store, preserving existing document-backed inventories).

  • void Save(ContainerId id, IReadOnlyList<StoredContainerSlot> slots)

    Saves (replaces) a container's stacks.

  • void SaveBatch(IReadOnlyList<ValueTuple<ContainerId, IReadOnlyList<StoredContainerSlot>>> saves)

    Saves several containers' stacks in ONE unit — used by TryExchange so a completed cross-container move persists both sides together (a crash mid-write can't leave items duplicated or lost). The default applies them sequentially (fine for the in-memory store); a durable store overrides with a real atomic multi-document write.

  • bool TryLoad(ContainerId id, out IReadOnlyList<StoredContainerSlot> slots)

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

Services you call 2

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

IContainerService

Server-authoritative containers of opaque item stacks, keyed by ContainerId. One genre-agnostic primitive that the role pieces sit on: a character bag, a bank, a stash, a guild vault, an equipment grid are all just container ids over this same engine. The game grants and consumes items through this API; a role piece exposes the wire ops that rearrange them. Every operation is all-or-nothing on the single poll thread. Items are opaque bytes — the game's vocabulary, stacked only when the container's policy says two payloads match. This is the abstract item SEAM: it lives in Crossplay.Container.Contracts (interfaces + DTOs, no engine impl) so a piece that only consumes containers (Mail's optional attachments, Trade, Crafting, Market) references the seam WITHOUT pulling the container engine assembly. The engine (ContainerService) lives in Crossplay.Container.Server and implements this.

  • event Action<ContainerId> Changed

    Raised after any change to a container, with its id.

  • int CountOf(ContainerId id, byte[] item)

    Total count of byte-equal items held in a container.

  • void EnsureContainer(ContainerId id, ContainerConfig config = null)

    Ensures a container exists, creating it (lazily loading any persisted stacks) with the given config the first time. Idempotent — opening an already-open container leaves its capacity and policy untouched. Config is optional; omitted, the engine defaults apply.

  • IReadOnlyList<ContainerSlot> Get(ContainerId id)

    The occupied slots of a container (framework value shape; do not mutate).

  • bool Give(ContainerId id, byte[] item, int count)

    Grants items into a container: fills matching stacks first, then empty slots. All-or-nothing.

  • bool Move(ContainerId id, int fromSlot, int toSlot)

    Move within one container: to-empty = move, stackable match = merge (remainder stays), else swap. False on an invalid move (bad index, empty source, policy refusal, destination full).

  • bool Take(ContainerId id, byte[] item, int count)

    Consumes items from a container across matching stacks. All-or-nothing.

  • bool TryExchange(ContainerId idA, IReadOnlyList<int> slotsA, ContainerId idB, IReadOnlyList<int> slotsB)

    Atomically moves the stacks at slotsA / slotsB BETWEEN two containers: both removals and both insertions succeed together or nothing changes at all. This is the seam a player trade (bag ↔ bag), a bank deposit/withdraw (bag ↔ bank), or an equip (bag ↔ equipment) all move through. Both containers persist as ONE unit.

IContainerView

Read-only view of a container's CURRENT occupancy, handed to CanPlace so a placement rule can SEE the state it judges — neighbouring items for a grid, filled slots for an adjacency rule, the whole layout for a shaped container. During a compound operation the view reflects the in-flight plan (earlier placements of the same operation are visible), which is exactly what an overlap/adjacency rule needs.

  • int AnchorOf(int slot)

    The anchor slot occupying slot: the slot itself when a stack sits there, the covering item's anchor when the cell is inside a multi-cell footprint, or -1 when the cell is free. Without footprints enabled this is simply "slot or -1".

  • int Capacity { get; }

    Total slot count of the container.

  • ReadOnlySpan<byte> ItemAt(int slot)

    The opaque payload of the stack at (or covering) slot; empty for a free cell. Decode your own item header from it (that is the game's vocabulary).

Configuration 1

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

ContainerOptions

Default parameters for containers that are opened without an explicit ContainerConfig. A container opened with a config overrides these per-container. Defaults here; never inlined in logic.

  • int DefaultCapacity { get; set; }

    Default slot capacity for a container opened without an explicit capacity.

  • int DefaultMaxStack { get; set; }

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

  • int MaxFootprintCells { get; set; }

    Largest footprint (covered cells) one item may report via IContainerPolicy.FootprintFor — also the size of the engine's reusable footprint buffer. Only relevant to containers opened with ContainerConfig.Footprints.

  • int MaxItemBytes { get; set; }

    Largest opaque item payload (bytes) accepted into any container.