stalls

Player Stalls

A player's OWN shop, standing where they left it: open a stall that escrows stock out of your bag, and anyone the game's IStallPolicy says can reach it browses and buys — the seller does nothing, and may be afk or offline. Nothing else covered it (shop is system-owned, market is asynchronous listings, trade needs both parties present and consenting). Presentation-agnostic: a market stall, a vending machine you place, a shop sign over an avatar, or a text for-sale list are the same data. Currency = IStallWallet, proximity/zone rules = IStallPolicy, so it names nothing spatial and no Economy type.

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

Seams you implement 1

Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayStalls(); and it wins.

IStallPolicy game SPI

WHERE a stall may open, and who may see one — the game's rules (GAP-13). This is the seam that keeps the piece free of any spatial reference. A stall is inherently proximate — you walk up to it — but "close enough" and "somewhere shops are allowed" are the game's answers, and a text-only or non-spatial game has different ones. The shipped default allows both, so the piece works standalone; the Hosting bridge supplies an interest-backed implementation when World is composed.

  • bool CanOpen(ISession seller, long sellerCharacterId)

    May this seller open a stall right now? (A town square, yes; a dungeon boss room, no.)

  • bool CanReach(ISession browser, long ownerEntityId)

    May this browser see/buy from that stall? (Usually: are they near enough.)

Providers you can swap 1

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

IStallWallet provider

Currency seam. The piece never references Economy — a game brings its own wallet, or Hosting bridges Economy in. The default refuses everything, so an unwired stall cannot sell for imaginary money.

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

    Gives currency back to the buyer's session (a failed settle).

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

    Pays an OWNER id — the seller may be afk or offline, with no session to scope.

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

    Takes currency from the buyer's session (scope-correct per currency).

Services you call 1

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

IStallService

Player stalls: selling from your OWN inventory to whoever walks up (GAP-13). The gap this closes. Nothing covered it. shop is system-owned (the game sells to players), market is asynchronous listings with no seller present, and trade is a face-to-face exchange needing both parties online and consenting to each other. A stall is none of those: it is one player's shop, standing where they left it, which strangers browse without the seller doing anything. Presentation-agnostic by construction: the piece holds a title string and priced lots and never says what a stall LOOKS like. A market stall, a vending machine you place, a shop sign over an afk avatar, and a text "for sale" list are all the same data.

  • Stall Browse(ISession browser, long ownerEntityId, out ushort reason)

    Reads a stall, if the game's policy lets this browser reach it.

  • bool IsOpen(long characterId)

    Whether this character currently has a stall open (a game gates movement/logout on it).

  • event Action<long, int, byte[], int, ushort, long, string> Sold

    Raised on each sale as (ownerCharacterId, slot, item, count, currencyId, price, buyerName).

  • event Action<long, bool, string> StallStateChanged

    Raised when a stall opens or closes, as (ownerCharacterId, isOpen, title). The hook a game turns into a shop sign — usually by mirroring it into the EntityState blob, which is exactly what that piece is for. The stall piece itself broadcasts nothing, because what a shop looks like is not its business.

  • bool TryBuy(ISession session, long ownerEntityId, int stallSlot, out ushort reason)

    Buys one slot of a stall. Settles atomically: debit → deliver → pay the seller.

  • bool TryClose(ISession session, out int slotsReturned, out int slotsUnreturned, out ushort reason)

    Closes the seller's stall, returning unsold stock.

  • bool TryOpen(ISession session, string title, IReadOnlyList<StallStockRequest> stock, out int slotsOpened, out ushort reason)

    Opens the seller's stall, escrowing the requested stock out of their bag.

Configuration 1

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

StallOptions

Stall tuning — defaults here, never inline (rule 4).

  • string BagContainerPrefix { get; set; }

    Container id prefix for a seller's bag, so the piece can move items without knowing what an inventory is. Matches the Inventory piece's convention.

  • int MaxSlotsPerStall { get; set; }

    Most slots one stall may hold.

  • int MaxTitleLength { get; set; }

    Longest shop title accepted; longer is trimmed.

Wire messages 9

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

6601 StallOpenRequest

Client → server: open my stall (GAP-13). Stock is escrowed OUT of the seller's bag the moment the stall opens, exactly as a market listing is — so a sale never has to reach into a live inventory, and the seller cannot sell the same sword twice by dropping it elsewhere while the stall is up.

6602 StallOpenResponse

Server → client: the verdict for a StallOpenRequest.

6603 StallCloseRequest

Client → server: close my stall. Everything unsold returns to my bag.

6604 StallCloseResponse

Server → client: the verdict for a StallCloseRequest.

6605 StallBrowseRequest

Client → server: look at somebody's stall, addressed by their ENTITY id.

6606 StallBrowseResponse

Server → client: a stall's title and stock.

6607 StallBuyRequest

Client → server: buy one slot of a stall.

6608 StallBuyResponse

Server → client: the verdict for a StallBuyRequest.

6609 StallSale

Server → the stall owner: something sold. Pushed because the whole point of a stall is that the seller is doing something else — or afk — while it trades. A sale they are never told about is indistinguishable from stock vanishing.

Unity services you inject 1

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

IStallClient

Player stalls, client side: open your own shop, look at someone else's, buy from it. The piece carries no presentation. What a stall LOOKS like is entirely the game's — a market stall you stand behind, a vending machine you place, a sign over an afk avatar, a text list of wares. Subscribe to the events and draw whatever fits. Remove this package and stalls simply stop; nothing else breaks.

  • bool MyStallOpen

    Whether this client's own stall is open (per the last server verdict).

  • event Action<bool, int, ushort> MyStallChanged

    The verdict for your own open/close attempt: (isOpen, slots, reasonCode).

  • event Action<StallBrowseResponse> StallShown

    A stall you asked to see (its stock is empty when the server refused — check the code).

  • event Action<StallBuyResponse> BuyAnswered

    The verdict for a purchase attempt.

  • event Action<StallSale> Sold

    Something of yours SOLD while you were doing something else. The reason it is pushed.

  • void Open(string title, StallStockRequest[] stock)

    Opens your stall with the given title and priced lots out of your bag.

  • void Close()

    Closes your stall; unsold stock returns to your bag.

  • void Browse(long ownerEntityId)

    Asks to see the stall belonging to an entity you can see.

  • void Buy(long ownerEntityId, int stallSlot)

    Buys one slot of a stall.