# Marketplace — the player-vendor index **Status:** On `edge` — servuo-plugins [#5](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/5), link [#19](https://gitea.whitlocktech.com/RunicGateway/link/pulls/19), website [#116](https://gitea.whitlocktech.com/RunicGateway/website/pulls/116). **Design:** [`docs/link/v3.md` §8](../link/v3.md) — Protocol 3.0 Part B/3. **Depends on:** [`CLILOCS.md`](CLILOCS.md) — without a cliloc table, listings render as item ids. The marketplace is a searchable index of every player vendor on the shard: what each shop is selling, for how much, and where it is standing. It is the same set the in-game **Vendor Search** gump reads, offered from outside the game — so a player can find the vanquishing kryss they want before logging in, and someone who does not play at all can see that the economy exists. Page: `/site/market`, plus `/site/market/vendors/:serial` for one shop. ## Three things the pages must say out loud Everything below follows from how the data is gathered, and each has a visible consequence the UI is required to surface. **1. The prices are not live.** The shard sweeps vendors **round-robin** — at most `Bridge.MarketSweepBatch` shops per tick — so a given shop can be a full cycle behind. The page carries a *"prices last refreshed N minutes ago"* banner driven by the **oldest** vendor row, not the newest: the one stale shop is the one that wastes somebody's trip. **2. A shop can be truncated.** `Bridge.MarketMaxListings` (250 by default) caps how many listings one frame carries. A commodity reseller with thousands of stacked resources is a real thing, and an uncapped frame for one is measured in megabytes. Over the cap the shop reports `truncated`, and the vendor page says *"showing 250 of 3,104 — this shop holds more than the shard publishes"* rather than presenting a partial shop as complete. **3. An item may have no name.** Items on the wire carry a cliloc id, not a name. On a shard whose operator has not converted a cliloc table ([`CLILOCS.md`](CLILOCS.md)) the honest render is the item id — never an invented label, which would be indistinguishable from a real one. ## Privacy: the player's own toggle wins Only vendors whose owner left the in-game **Vendor Search** flag ON are ever sent to the site. A player who hides their shop in game is hidden here too, and no admin setting overrides that. When they hide one that was already indexed, the shard emits `vendor.listing.remove` and the row is deleted — so revoking consent takes effect, it does not merely stop refreshing. Shop name, owner character name and location default to **Everyone**, because the stock Vendor Search gump already shows exactly that set to any player in game. They remain admin-configurable; see [`SHARD_VISIBILITY.md`](SHARD_VISIBILITY.md). Account names and website user ids never cross the wire at all. ## How it is put together ``` ServUO uo-link sidecar website ────── ─────────────── ─────── BridgeMarket.cs vendors table shard_vendors round-robin sweep ──────► (whole frame blob) ──────► shard_vendor_items per-vendor diff GET /market (paged) + display_name vendor.listing resolved at ingest vendor.listing.remove ``` **The shard side** walks at most `MarketSweepBatch` vendors per tick from a persistent cursor, diffs each against what it last published, and emits a whole frame for any shop that moved. Per-tick cost is therefore bounded by the batch, not by how many vendors the world holds — full coverage takes `ceil(vendors / batch) × MarketSweepSeconds`. **The sidecar** stores each frame whole and serves `GET /market`, its only paged read. It normalizes nothing and defines no audiences: it is a dumb forwarder, and search is the website's job. **The website** splits each frame into a vendor row and its listings, replacing that vendor's whole listing set inside one transaction (the frame is authoritative for that vendor, never a delta). Item names are resolved against the cliloc table **on the way in** and stored denormalized, which is what makes search-by-name possible and keeps the cliloc table off the hot path. ## Operating it Everything is in `Config/Bridge.cfg` on the shard. There is nothing to configure on the website. | Setting | Default | What it does | |---|---|---| | `MarketEnabled` | `true` | Master switch. Off publishes nothing; the page shows an empty index. | | `MarketSweepSeconds` | `60` | Tick interval. | | `MarketSweepBatch` | `25` | Vendors inventoried per tick. Clamped 1..500. | | `MarketMaxListings` | `250` | Per-shop listing cap, after which `truncated`. Clamped 1..5000. | **Faster coverage vs. per-tick cost.** Lowering `MarketSweepSeconds` or raising `MarketSweepBatch` both refresh the index sooner and both cost more per tick. The expensive part is the item walk, which recurses into every container a vendor is selling — so a shard of big shops should raise the interval rather than the batch. `[bridge status` reports the sweep, including `lastMs` and `maxMs`: ``` market(enabled=True sweeps=42 scanned=108 emitted=27 removed=0 skipped=0 truncated=0 tracked=27 vendors=27 cursor=2 batch=25 lastMs=0.31 maxMs=15.40) ``` A tick over **50 ms** prints a rate-limited warning naming the knob: ``` [Bridge] market sweep took 82.4 ms (budget 50 ms) - lower Bridge.MarketSweepBatch (now 25) if this persists ``` Measured on a shard with 27 vendors × 40 listings (209k items, 43k mobiles): **15.4 ms** for the first cold tick of 25 vendors, **0.3 ms** in steady state — the diff is what makes an unchanged world nearly free. Note the arithmetic: 25 *full* shops at the 250-listing cap is 6,250 items ≈ 95 ms, over budget. Real shops hold tens, which is why 25 is the default and why the warning exists. `[bridge sweepnow` runs one tick immediately; `[bridge reload` re-reads the settings above without a restart. ## Names arriving late Item names come from the cliloc table, and the market sweep will **not** re-send an unchanged shop just because the site learned what its items are called. So a cliloc import triggers a bulk re-resolution of every stored listing — otherwise an operator who configures clilocs after the first sweep would see item ids until every shop happened to change on its own. It runs after a boot import and after an admin import, takes ~50 ms per thousand listings, and never throws: a failure leaves names exactly as they were. ## API All under `/api/v1/public/shard`, gated by the `market` feature and **rate-limited** — these are the first genuinely expensive public reads on the site (a `LIKE` scan plus a `COUNT` over what is typically the largest `shard_*` table, reachable with no session). | Route | What | |---|---| | `GET /market` | Search. Returns **listings**, not vendors — "who sells X and for how much" is the question. `?q=&minPrice=&maxPrice=&itemId=&map=®ion=&sort=&limit=&offset=`, `sort ∈ {price_asc, price_desc, recent}`. | | `GET /market/meta` | Index size, staleness, and which facets and regions actually hold vendors — so a client builds its filters without running a search it will discard. | | `GET /market/vendors/:serial` | One shop and its listings. **404** for a serial the index has never seen, which also covers a vendor since dismissed or hidden — to an anonymous caller those are the same answer. | `q` matches the resolved display name **or** the item's own literal name, because an item with a player-set name (most of what is worth searching for on a player-run shard) may carry a generic cliloc. `%` and `_` in a query are escaped: they are `LIKE` metacharacters, not SQL ones, so parameterization alone would let a search for `%` match every listing on the shard. Full schemas are in the OpenAPI spec (`ShardMarketPage`, `ShardMarketVendor`, `ShardMarketMeta`, `ShardMarketListing`, `ShardMarketLocation`). ## Tables `shard_vendors` (one row per shop) and `shard_vendor_items` (one row per priced listing). Both are ingest-owned; nothing else writes to them. No foreign keys, in keeping with every other `shard_*` table — the ingest transaction is what keeps them consistent, and an FK would turn a malformed frame into a failed write rather than a dropped row. There is deliberately **no `payload` column** on `shard_vendors`, unlike the points board next door. The board's top-N is a fixed-size list read whole, so it lives in JSON; here the items *are* the searchable rows, so they are normalized and there is nothing left worth duplicating. The sidecar keeps the whole blob, because outage resilience is its job. `shard_vendor_items.display_name` is denormalized and indexed (alone, and composite with `price` for "cheapest matching X"). See "Names arriving late" above for how it is kept current.