shop

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.

Category Economy Seams 7 Services 2 Options 1 Wire ids 9 Unity types 2
Server
services.AddCrossplayShop();
Unity package
com.crossplay.shop
Depends on
container

Providers you can swap 7

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.

IShopFulfilment provider

How a paid purchase reaches the buyer — called AFTER the debit. The default puts the goods straight into the buyer's bag; a game may instead accept and deliver later (a drone drop, a mail parcel, a counter pick-up). Register yours before AddCrossplayShop (a TryAdd seam).

  • bool TryFulfil(ISession buyer, ushort shopId, ShopEntry entry, int count, out ushort reasonCode)

    Delivers (or schedules delivery of) count purchases of entry.

IShopHoldings provider

How many units of a line the buyer holds, for MaxHeld. The default counts the item in the buyer's bag; a game adds what is still on its way (or counts other containers). Register yours before AddCrossplayShop (a TryAdd seam).

  • int Held(ISession buyer, ShopEntry entry)

    Units of entry's item the buyer holds right now.

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.

  • void PushContents(ISession session, ushort shopId)

    Re-sends a shop's contents (and extras) to a player — for a game whose holdings changed outside the shop (a delivery landed, an item was used up), so the picker's limits refresh. Defaulted (rule 25): does nothing.

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

  • bool PushContentsAfterBuy { get; set; }

    Re-send ShopContents (and its extras) to the buyer after every committed buy, so remaining limits, stock and holding room refresh without a re-Open. Off by default: a client re-Opens, as before.

  • 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 9

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.

5709 ShopEntryExtras

Server → client: per-entry extras for a shop, sent straight after its ShopContents — and ONLY for a shop whose catalog uses them (a holding cap, a bundle, or game data), so a shop that uses none sends exactly what it always did. Its own message rather than new ShopEntryDto fields: a client built before them keeps reading ShopContents unchanged, and simply never handles this.

Unity seams you implement 1

The client half's sockets. Bind your implementation in the client context and Crossplay's client calls it — this is where your models, animators, UI and controls plug in.

IShopEntryExtrasClient unity SPI

Per-entry extras a shop sends beside its contents (opt-in server side: an entry's MaxHeld, UnitsPerPurchase or Data): how many more units the buyer may hold, the bundle size, and the game's own data for the line. Its own interface, not new IShopClient members, so no implementation of that published interface has to change (rule 25). A shop that uses none of them sends nothing here.

  • event Action<ShopEntryExtras> ExtrasReceived

    Raised when a shop's extras arrive (straight after its ShopContents).

  • bool TryGetExtras(ushort shopId, ushort entryId, out ShopEntryExtraDto extras)

    The latest extras for one line of a shop, if that shop sent any.

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.