Economy

Shop / Vendors

System-owned stores selling/buying OPAQUE items for game currencies: catalog stock, per-buyer daily caps, finite stock, buyback ring. Currency/access/appraisal/pricing are seams (Economy bridges the wallet); WHAT a shop is — NPC, vending machine, terminal — is the game's. Server-only wire verbs; empty catalog = inert.

Server
services.AddCrossplayShop();
Unity package
com.crossplay.shop
Depends on
container

Providers you can swap 5

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

IShopAccess provider

The game's access rule: may THIS player use THIS shop right now? Range checks, faction gates, reputation tiers, opening hours — all the game's vocabulary; the default allows everyone. Register yours before AddCrossplayShop (a TryAdd seam).

  • bool Allow(ISession session, long ownerId, ushort shopId, out ushort reasonCode)

    True when the player may use the shop. On refusal set reasonCode to a notification code (0 falls back to ShopNotAllowed).

IShopAppraiser provider

The game's sell-side rule: what does THIS shop pay for THIS item? The item blob is opaque to the piece, so payouts are inherently the game's vocabulary. The default refuses everything — selling is OPT-IN; register yours before AddCrossplayShop (a TryAdd seam).

  • bool TryAppraise(long ownerId, ushort shopId, byte[] item, out ushort currencyId, out long unitPrice)

    True when the shop buys this item, yielding the payout currency and unit price.

IShopCounterStore provider

Storage seam for per-buyer daily purchase counters. The default is in-memory (counters reset with the server process — the donor-parity MVP); register a durable implementation to survive restarts.

  • void Add(int utcDayNumber, long ownerId, ushort shopId, ushort entryId, int count)

    Records a committed purchase of count on the given UTC day number.

  • int UsedOn(int utcDayNumber, long ownerId, ushort shopId, ushort entryId)

    How many of (shop, entry) this owner bought on the given UTC day number.

IShopPricing provider

The game's buy-side price rule — personalizes an entry's unit price per buyer (reputation discounts, dynamic economies, sales). The default is the catalog price. Register yours before AddCrossplayShop (a TryAdd seam).

  • long GetUnitPrice(long ownerId, ushort shopId, ShopEntry entry)

    The unit price THIS buyer pays for THIS line right now.

IShopWallet provider

The currency seam Shop spends and pays through — the piece references NO concrete currency system. A game using the Economy piece lets the Hosting bridge implement this; any other game registers its own before AddCrossplayShop (a TryAdd seam).

  • bool TryCredit(ISession session, ushort currencyId, long amount, string reason)

    Give amount of a currency to the player — sale proceeds, or the refund path when delivery fails after a debit. False = refused / capped.

  • bool TryDebit(ISession session, ushort currencyId, long amount, string reason)

    Take amount of a currency from the buyer. False = can't afford / refused.

Services you call 2

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

IShopClock

Clock seam so daily windows are testable (and swappable for a game with its own calendar). The default reads UtcNow.

  • DateTime UtcNow { get; }

    The current UTC time.

IShopService

Server-side Shop API — what the wire handlers call, and what game code can call directly (an interaction verb, a dialog action, a scripted grant — same gates, same commit, no wire needed). Everything is keyed by the acting player's owner (character) id via the Core identity seam.

  • event Action<long, ushort, ushort, int> Bought

    Raised after a COMMITTED buy with (owner id, shop id, entry id, count) — the composition hook for quests, achievements, analytics… The piece never interprets it.

  • event Action<long, ushort, int, long> Sold

    Raised after a COMMITTED sale with (owner id, shop id, count, total paid).

  • bool TryBuy(ISession session, ushort shopId, ushort entryId, int count, out ushort reasonCode)

    Buys count of a roster line: catalog gate → access → daily limit → stock → wallet debit → container delivery (refund on failure) → counters/stock commit. Returns false with a ShopNotificationCodes value on refusal; a refused buy never spends and never grants.

  • bool TryBuyback(ISession session, ushort shopId, byte slot, out ushort reasonCode)

    Re-buys a buyback line at the price the shop paid: wallet debit → container delivery (refund on failure) → line removal. Returns false with a code on refusal.

  • bool TrySell(ISession session, ushort shopId, int slot, int count, out ushort reasonCode, out long paid)

    Sells count items from the seller's bag slot: access → authoritative stack read → appraisal (the game's SPI) → item take → wallet credit (items restored when the credit refuses) → buyback push. Returns false with a code on refusal.

Configuration 1

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

ShopOptions

Configurable shop parameters (defaults here; never inlined in logic).

  • string BagKind { get; set; }

    The container kind the buyer's goods live in — pairs with the role piece that owns player bags (the Inventory convention bag:{ownerId}). A game with its own container vocabulary points this at its kind.

  • int BuybackCapacity { get; set; }

    How many recently-sold lines each player keeps per shop for buyback. Oldest lines fall off first. 0 disables the buyback tab entirely.

  • int MaxCountPerTransaction { get; set; }

    Upper bound on the count of one buy/sell request (defense against overflow and absurd requests; a request above it refuses with CountInvalid).

  • Dictionary<ushort, ShopDefinition> Shops { get; set; }

    The shop catalog, keyed by the game-defined shop id. Empty (the default) leaves the piece inert: every request answers UnknownShop and nothing else happens — installing the piece without configuring it changes no behavior.

Wire messages 8

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

5701 ShopOpenRequest

Client → server: "open shop X". WHAT a shop id maps to — an NPC, a vending machine, a terminal, a menu button — is 100% the game's; the piece only knows the number.

5702 ShopContents

Server → client: the roster of one shop, personalized to the asking buyer (live prices, remaining daily allowance, their own buyback tab).

5703 ShopBuyRequest

Client → server: "I buy Count of entry Y from shop X".

5704 ShopBuyResponse

Server → buyer: the buy verdict. The state changes themselves (wallet debit, items appearing) replicate through the Economy / Container wires — this message only says yes/no.

5705 ShopSellRequest

Client → server: "sell Count from my bag slot S to shop X". The item is identified by the slot — the server reads the authoritative stack, never the client's claim.

5706 ShopSellResponse

Server → seller: the sell verdict and the total paid out.

5707 ShopBuybackRequest

Client → server: "re-buy my buyback slot S in shop X" — undoes a recent sale at the price the shop paid.

5708 ShopBuybackResponse

Server → buyer: the buyback verdict.

Unity services you inject 1

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

IShopClient

The Shop piece, client side: browse a system-owned store, buy a roster line, sell from a bag slot, and re-buy a recent sale. Items are OPAQUE blobs (the same bytes the Inventory client holds); wallet changes replicate through the Economy wire — a shop UI binds ContentsReceived to its list and calls the verbs on click. Refusals carry a ShopNotificationCodes value in the responses' ReasonCode.

  • void Open(ushort shopId)

    Ask for shop X's roster (answered via ContentsReceived — personalized: live prices, your remaining daily allowance, your buyback tab).

  • void Buy(ushort shopId, ushort entryId, ushort count)

    Buy count of a roster line.

  • void Sell(ushort shopId, int slot, ushort count)

    Sell count items from your bag slot (the server reads the authoritative stack — never the client's claim).

  • void Buyback(ushort shopId, byte slot)

    Re-buy buyback slot S at the price the shop paid.

  • event Action<ShopContents> ContentsReceived

    A shop roster arrived (after Open).

  • event Action<ShopBuyResponse> BuyResultReceived

    The server's verdict on one of your buys.

  • event Action<ShopSellResponse> SellResultReceived

    The server's verdict on one of your sells (with the total paid).

  • event Action<ShopBuybackResponse> BuybackResultReceived

    The server's verdict on one of your buybacks.