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.
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> SoldRaised on each sale as (ownerCharacterId, slot, item, count, currencyId, price, buyerName).
-
event Action<long, bool, string> StallStateChangedRaised 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.
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.
StallOpenResponse Server → client: the verdict for a StallOpenRequest.
StallCloseRequest Client → server: close my stall. Everything unsold returns to my bag.
StallCloseResponse Server → client: the verdict for a StallCloseRequest.
StallBrowseRequest Client → server: look at somebody's stall, addressed by their ENTITY id.
StallBrowseResponse Server → client: a stall's title and stock.
StallBuyRequest Client → server: buy one slot of a stall.
StallBuyResponse Server → client: the verdict for a StallBuyRequest.
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 MyStallOpenWhether this client's own stall is open (per the last server verdict).
-
event Action<bool, int, ushort> MyStallChangedThe verdict for your own open/close attempt: (isOpen, slots, reasonCode).
-
event Action<StallBrowseResponse> StallShownA stall you asked to see (its stock is empty when the server refused — check the code).
-
event Action<StallBuyResponse> BuyAnsweredThe verdict for a purchase attempt.
-
event Action<StallSale> SoldSomething 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.