Documents order 5b across the four repos, and records what building it changed about §8 as designed. - NEW website/MARKETPLACE.md — the operator guide: what the pages must say out loud and why, the privacy contract (the player's in-game Vendor Search toggle wins, and no admin setting overrides it), the Bridge.cfg knobs and how they trade against each other, and the measured sweep costs. - INTEGRATION.md — catalog entry for vendor.listing / vendor.listing.remove with its six consumer gotchas, and the GET /market REST section (the sidecar's only paged read, and why it orders by serial rather than shop name). - BACKEND_DESIGN.md — shard_vendors / shard_vendor_items, the routes, and the marketplace search as the only rate-limited public read. - SHARD_VISIBILITY.md — why the market's fields default to Everyone (the in-game gump already shows exactly that set), why location is one setting covering four things, and why hiding the owner name without the owner id achieves nothing. - PLAN.md — the amortized round-robin as the one sweep pattern the bridge did not previously have, and an update to §7's cliloc note: pushing name resolution to the plugin was never an option, because ServUO cannot read a modern client's compressed cliloc files either. - v3.md §8.8 — the four things the build settled differently, chief among them that §8.1's FLAT location payload would have made Part A's pre-wired market.location rule inert, exactly like the characterName miss one part earlier. Co-Authored-By: Claude <noreply@anthropic.com>
8.9 KiB
Marketplace — the player-vendor index
Status: On edge — servuo-plugins #5, link #19, website #116.
Design: docs/link/v3.md §8 — Protocol 3.0 Part B/3.
Depends on: 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) 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.
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.