Purchases
Data-driven purchase catalog: wallet stat, per-buyer live price (stored as a stat, so it replicates) with growth, and capped stat effects. Server-only wire verb; empty catalog = inert.
Seams you implement 1
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayPurchases(); and it wins.
IPurchasePolicy game SPI
Whether a purchase may happen RIGHT NOW — the SPI that keeps commerce timing the game's. The catalog says what a purchase costs and does; this seam says when it is allowed at all (a phase window, a vendor range, a truce rule…) — the piece never knows those words. Consulted after the catalog lookup and BEFORE anything is touched: a denied purchase seeds no price, spends nothing. Default: AllowAllPurchasePolicy (always allowed). How to implement. Register your policy in DI BEFORE AddCrossplayPurchases (a TryAdd seam). Runs on the poll thread once per attempt — keep it allocation-free. Return false with a reason (typically PurchaseNotificationCodes.PurchaseNotAllowed) to refuse; the buyer gets it in the PurchaseResponse.
-
bool Allow(long buyerEntityId, byte purchaseId, out ushort reasonCode)May buyerEntityId buy purchaseId now?
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IPurchaseService
Server-side API for the data-driven purchase catalog. A purchase changes numbers, that's all it knows: resolve the definition, read the buyer's live price, check the effect's caps, debit the wallet stat, apply the effect, grow the price — every mutation goes through the Stats piece, so everything replicates on the ordinary Stats wire. WHAT any purchase means is the game's. Clients trigger it with the PurchaseRequest wire verb; the game can also call TryPurchase directly from server logic (an NPC vendor, an interaction verb, a scripted grant) — same gates, same commit, no wire needed.
-
event Action<long, byte, float> PurchasedRaised after a COMMITTED purchase with (buyer entity id, purchase id, price paid) — the composition hook for audit trails, achievements, analytics… The piece itself never interprets it.
-
bool TryPurchase(long entityId, byte purchaseId, out ushort reasonCode)Runs one purchase for entityId: catalog lookup → lazy price-stat seed → cap checks → wallet check → commit (debit, effect, price growth). Returns false with a PurchaseNotificationCodes value in reasonCode on refusal; a refused purchase never touches the wallet.
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
PurchaseOptions
Configurable purchase parameters (defaults here; never inlined in logic).
-
Dictionary<byte, PurchaseDefinition> Catalog { get; set; }The purchase catalog, keyed by the game-defined purchase id. Empty (the default) leaves the piece inert: every request answers UnknownPurchase and nothing else happens — installing the piece without configuring it changes no behavior.
Wire messages 2
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
PurchaseRequest Client → server: "I buy catalog entry X". WHAT a purchase id means (an upgrade, a refill, a perk, a level of anything…) is 100% the game's vocabulary — the piece resolves it to numbers only: a wallet-stat debit, a stat effect, a price bump. Nothing else travels.
PurchaseResponse Server → buyer: the verdict. The state changes themselves (wallet debit, stat effect, price growth) replicate through the ordinary Stats wire — this message only says yes/no.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IPurchaseClient
The Purchases piece, client side: fire "I buy catalog entry X" at the server and receive the private verdict. WHAT a purchase id means (an upgrade, a refill, a perk, a level of anything…) is 100% the game's vocabulary — the piece only moves the verb and the yes/no. Prices are stats: the buyer's live price replicates as an ordinary stat (the server catalog's PriceStatId), and the wallet is a stat too — so a shop UI binds its price labels and wallet counter to the Stats client and simply calls Use on click; the wallet debit, the stat effect and the price bump all arrive through the ordinary Stats wire, never through this piece. Refusals carry a PurchaseNotificationCodes value in ReasonCode (unknown purchase, missing wallet, capped out, or the game's policy said no).
-
void Use(byte purchaseId)Send "I buy catalog entry X" to the server (reliable). The verdict arrives via ResultReceived.
-
event Action<PurchaseResponse> ResultReceivedThe server's private verdict on one of your purchases (success, or a refusal reason).