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.
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> BoughtRaised 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> SoldRaised 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.
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.
ShopContents Server → client: the roster of one shop, personalized to the asking buyer (live prices, remaining daily allowance, their own buyback tab).
ShopBuyRequest Client → server: "I buy Count of entry Y from shop X".
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.
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.
ShopSellResponse Server → seller: the sell verdict and the total paid out.
ShopBuybackRequest Client → server: "re-buy my buyback slot S in shop X" — undoes a recent sale at the price the shop paid.
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> ContentsReceivedA shop roster arrived (after Open).
-
event Action<ShopBuyResponse> BuyResultReceivedThe server's verdict on one of your buys.
-
event Action<ShopSellResponse> SellResultReceivedThe server's verdict on one of your sells (with the total paid).
-
event Action<ShopBuybackResponse> BuybackResultReceivedThe server's verdict on one of your buybacks.