Economy

Market

Escrowed listings + lock-guarded buyout over Inventory + Economy, with durable Scheduler expiry.

Server
services.AddCrossplayMarket();
Unity package
com.crossplay.market
Depends on
container

Providers you can swap 3

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

IMarketExpirySchedule provider

The durable-expiry seam a Market needs: schedule (and re-schedule) a listing's auto-return to the seller — without the piece compiling against the concrete Scheduler. OPTIONAL: the NullMarketExpirySchedule default makes listings simply not auto-expire (cancel/buyout still free the escrow, so nothing is lost). The Hosting composition bridges this to the Scheduler piece with ZERO piece-to-piece reference (exactly like IMarketWallet bridges to Economy), or a game plugs in its own timer.

  • void RescheduleExpiryIn(long listingId, double delaySeconds)

    Re-check listing listingId after delaySeconds — a contended-lock or full-seller-bag retry so escrowed items are never lost. No-op when unwired.

  • void ScheduleExpiry(long listingId, long fireAtUtcMs)

    Schedule listing listingId to auto-return at fireAtUtcMs by calling IMarketService.ExpireListing when that time is reached.

IMarketStore provider

Listing persistence SPI (in-memory default; document-backed across restarts/nodes). Register a durable implementation with services.AddSingleton<IMarketStore, MyStore>() before AddCrossplayMarket (a TryAdd seam) — or supply it via AddCrossplayPersistentStores.

  • IReadOnlyList<MarketListing> All()

    Snapshots every stored listing (open and expired-but-unswept) for browse/cap checks.

  • bool Delete(long listingId)

    Removes a listing.

  • MarketListing Get(long listingId)

    Fetches a listing by id, or null when it does not exist (sold / cancelled / expired).

  • long NextId()

    Allocates the next unique listing id (monotonic, unique across live listings).

  • void Save(MarketListing listing)

    Inserts or replaces a listing (upsert keyed by Id).

IMarketWallet provider

The currency seam a Market needs: debit the buyer, credit the seller. A Market REQUIRES a wallet (a listing can't be priced in nothing) — but NOT the concrete Economy piece. The Hosting composition bridges IEconomyService to this with zero piece-to-piece reference (exactly like IDailyRewardGrantor bridges DailyRewards → Economy), or a game plugs in its own wallet. Absent a real wallet, DenyMarketWallet refuses every currency move, so an item-only server still lists / cancels / expires — only buyouts (which move currency) fail.

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

    Give amount of a currency back to the buyer's session — the refund path when delivery fails after the debit. Scope-resolved exactly like TryDebit so the refund lands in the same purse the debit took from. False = refused / capped.

  • bool TryCreditOwner(long ownerId, ushort currencyId, long amount, string reason)

    Pay the SELLER their sale proceeds. The seller is typically OFFLINE (an async market settles whenever a buyer buys), so no seller session exists — only their owner id is known, and this keys by it. For an account-scoped currency with the Characters piece installed that credits the owner (character) row: an offline party's account cannot be resolved without a live session (there is no character→account map here). The buyer paths above, which DO carry a session, are always scope-correct. False = refused / capped.

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

    Take amount of a currency from the buyer's session. Session-keyed so scope resolves PER CURRENCY: an account-scoped currency debits the account, a character-scoped one the selected owner — so a buyout paid in an account-wide currency draws down the account purse even with a character selected. False = can't afford / refused.

Services you call 2

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

IMarketClock

Wall-clock seam (unix UTC ms) so expiry math is testable. Piece-local on purpose.

  • long UtcNowMs { get; }

    The current time as unix-epoch milliseconds (UTC).

IMarketService

Server-side Market API — escrowed listings + lock-guarded buyout + cancel + durable expiry. This is what the wire handlers call, and what game code can call directly. Everything is keyed by the acting player's CHARACTER (owner) id (the account id when no Characters piece is installed). Resolve it from DI: [Inject] IMarketService market. Clients can only browse and request buys/cancels over the wire; escrow and atomicity are enforced here, server-side.

  • IReadOnlyList<MarketListingEntry> Browse()

    Snapshot the current open listings, freshest first and clamped to MaxBrowseCount (expired-but-unswept listings are excluded).

  • void ExpireListing(long listingId)

    Return an expired listing's escrow to the seller (the durable-expiry action). Driven by the composition's timer bridge when a listing comes due; Market itself never compiles against a scheduler.

  • event Action<long, long, long> Sold

    Raised once per completed sale, with (listingId, sellerCharacterId, buyerCharacterId). Game code can subscribe to react (analytics, achievements) — no piece-to-piece reference required.

  • bool TryBuy(ISession session, long listingId, out ushort reason)

    Buy a listing outright. Settles atomically under a per-listing lock (debit buyer → deliver items → credit seller); every failure path restores exactly what it took.

  • bool TryCancel(ISession session, long listingId, out ushort reason)

    Cancel one's own listing — returns the escrowed items to the seller's bag. Only the seller may cancel.

  • bool TryList(ISession session, int slotIndex, int count, ushort currencyId, long price, out long listingId, out ushort reason)

    List a slot from the seller's bag for sale — escrows the items OUT of the bag immediately.

Configuration 1

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

MarketOptions

Market tuning (defaults here, never inline).

  • double ExpiryRetrySeconds { get; set; }

    Retry delay when an expiry return cannot fit the seller's inventory.

  • double ListingDurationHours { get; set; }

    How long a listing stays open before the items return to the seller.

  • int MaxBrowseCount { get; set; }

    Most listings a single browse returns (freshest first).

  • int MaxListingsPerCharacter { get; set; }

    Open listings per character (0 = unlimited).

  • int SettleLockAttempts { get; set; }

    Retry attempts when a settlement lock is contended.

  • int SettleLockRetryMs { get; set; }

    Delay between settlement-lock retries (ms).

  • double SettleLockTtlSeconds { get; set; }

    Settlement-lock lease per listing (self-expires if a node dies mid-settle).

Wire messages 8

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

3401 MarketListRequest

Client → server: list one of my inventory slots for sale. On acceptance the server escrows the items OUT of the seller's bag immediately (they live on the listing, so a later sale never touches the seller's inventory). Sent by the seller; answered by a MarketListResponse.

3402 MarketListResponse

Server → client: the verdict for a MarketListRequest. On failure the seller also receives a notification carrying the same reason (see MarketNotificationCodes).

3403 MarketBrowseRequest

Client → server: request the current open listings. Carries no fields; answered by a MarketBrowseResponse.

3404 MarketBrowseResponse

Server → client: the open listings, freshest first and clamped to the server's configured page size (MarketOptions.MaxBrowseCount). Reply to a MarketBrowseRequest.

3405 MarketBuyRequest

Client → server: buy a listing outright at its full price. The server settles atomically under a per-listing lock (debit buyer → deliver items → credit seller); answered by a MarketBuyResponse.

3406 MarketBuyResponse

Server → client: the verdict for a MarketBuyRequest. On failure the buyer also receives a matching notification (see MarketNotificationCodes).

3407 MarketCancelRequest

Client → server: cancel my own listing. The escrowed items return to the seller's inventory; answered by a MarketCancelResponse. Only the seller may cancel.

3408 MarketCancelResponse

Server → client: the verdict for a MarketCancelRequest. On failure the seller also receives a matching notification (see MarketNotificationCodes).

Unity services you inject 1

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

IMarketClient

The Market piece, client side: list/browse/buy/cancel. Item blobs and currency ids are the GAME's vocabulary — this client never interprets them.

  • UniTask<MarketListResponse> ListAsync(int slotIndex, int count, ushort currencyId, long price, CancellationToken ct = default)

    Lists an inventory slot for sale (the items move into escrow).

  • UniTask<MarketBrowseResponse> BrowseAsync(CancellationToken ct = default)

    Browses open listings (freshest first).

  • UniTask<MarketBuyResponse> BuyAsync(long listingId, CancellationToken ct = default)

    Buys a listing outright.

  • UniTask<MarketCancelResponse> CancelAsync(long listingId, CancellationToken ct = default)

    Cancels my own listing (the items return).

UI views you can replace 1

Each ships a working uGUI panel you can drag into a scene. Substitute your own view to keep the logic and change the look — the presenter never knows.

ICrossplayMarketView

Narrow contract the presenter drives. UI-only: render calls in, user intents out.