docs(link): the player-vendor marketplace (Protocol 3.0 §8)

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>
This commit is contained in:
2026-07-29 09:52:06 -05:00
parent 70d49b7792
commit 6ce60a82c3
7 changed files with 440 additions and 8 deletions

View File

@@ -407,6 +407,50 @@ Two values carry non-obvious meanings, both set by the plugin and both documente
cliloc rather than a literal. Listing therefore orders by `COALESCE(name, system)`, so boards
awaiting cliloc resolution sort by their own key rather than clumping together under NULL.
### shard_vendors / shard_vendor_items — the player-vendor marketplace (Protocol 3.0)
The shard-wide shop index, fed by `vendor.listing` / `vendor.listing.remove`. One row per player
vendor and one per priced listing. Full operator detail in [`MARKETPLACE.md`](MARKETPLACE.md); the
design is `docs/link/v3.md` §8.
| Table | Shape |
|---|---|
| `shard_vendors` | `serial` (PK), `shop_name`, `owner_serial`, `owner_name`, `map`/`x`/`y`/`z`, `region`, `house`, `item_count`, `item_total`, `truncated`, `t`, `updated_at`. Indexes on owner, map, region and `updated_at`. |
| `shard_vendor_items` | `id` (PK), `vendor_serial`, `serial`, `item_id`, `hue`, `amount`, `price`, `name`, `cliloc`, `display_name`, `child`. Indexes on `vendor_serial`, `price`, `item_id`, `display_name`, and `(display_name, price)`. |
**Ingest is per-vendor and authoritative**: the frame is the whole shop, so ingest is
delete-then-insert of that vendor's listings inside one transaction. All-or-nothing matters
specifically because the two writes are "the shop" and "what is in it" — a failure between them
leaves a shop advertising an inventory it no longer has, which is visibly wrong and indistinguishable
from a genuinely empty shop. No foreign keys, consistent with every other `shard_*` table.
**There is deliberately no `payload` column**, unlike `shard_points_boards` directly above. 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 nothing is left worth duplicating. The sidecar keeps the
whole blob — outage resilience is its job, search is ours.
Market state, not events: neither kind is in `LOGGED_KINDS`, and this is the strongest case of the
three v3 kinds. One frame carries up to 250 listings and the sweep re-emits a shop on any price
change, so logging would turn `shard_events` into a price history nobody reads.
Two columns carry non-obvious meanings:
- **`item_count` vs `item_total`.** `item_count` is what the frame published; `item_total` is what
the shop actually holds. They differ when `truncated` — the shard caps listings per frame
(`Bridge.MarketMaxListings`, 250 by default), and a commodity reseller with thousands of stacks
genuinely exceeds it. Any UI must show both or it presents a partial shop as complete.
- **`display_name` is denormalized at ingest**, resolved from the item's literal `name` (preferred —
a player set it, so it is more specific) else its `cliloc` against `shard_clilocs`. Resolving at
query time would put the cliloc table on the hot path and make search-by-name impossible. Because
the shard's diff sweep will not re-send an unchanged shop just because the site learned what its
items are called, **a cliloc import triggers a bulk re-resolution** of this column (after a boot
import and after an admin import; ~50 ms per thousand rows, never throws).
`updated_at` is written explicitly on every upsert rather than left to `ON UPDATE CURRENT_TIMESTAMP`,
which MariaDB does not fire when every column is written back unchanged. A shop re-published
identically is still *freshly confirmed*, and without this the staleness banner would age a perfectly
current shop forever.
### shard_feature_visibility — per-feature audience config (Protocol 3.0)
One row per shard feature: `feature` (PK), `enabled`, `audience` (a rung on the ladder in §6.5),
@@ -745,6 +789,9 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
| GET | `/shard/ruleset` | the shard's own published ruleset (Protocol 3.0 `world.ruleset`): expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules, the save/restart schedule. Served from `shard_ruleset`, so it renders while the shard is down; live via `world.ruleset` on `/shard/stream`. Behind `requireFeature('ruleset')`. **`null`** means the shard has never published one — a real answer, distinct from a published ruleset. `caps.skill` / `caps.totalSkill` are in **tenths** (1000 = 100.0). |
| GET | `/shard/points` | every points/loyalty leaderboard the shard publishes (Protocol 3.0 `points.board`) — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, … Served from `shard_points_boards`, so it renders while the shard is down; live via `points.board` on `/shard/stream`. Behind `requireFeature('leaderboards')`, ordered by display name. **`maxPoints: 0` means uncapped** (the common case), and `nameString` is usually `null` with `nameNumber` holding a cliloc — resolve client-side or humanise the `system` key. |
| GET | `/shard/points/:system` | one board by the shard's `PointsType` name (e.g. `QueensLoyalty`); `:system` must match `/^[A-Za-z][A-Za-z0-9_]{0,47}$/` or **400** before any query runs. **404** = the shard has never published that system, which is distinct from a published board nobody has scored in yet (**200** with an empty `top`). |
| GET | `/shard/market?q=&minPrice=&maxPrice=&itemId=&map=&region=&sort=&limit=&offset=` | search the player-vendor marketplace (Protocol 3.0 `vendor.listing`). Returns **listings**, not vendors — "who sells X and for how much" is the question, and a vendor-shaped result would make every caller flatten the shops back out. Served from `shard_vendors` + `shard_vendor_items`, so it renders while the shard is down. Behind `requireFeature('market')` **and rate-limited** — the first genuinely expensive public read on the site (a `LIKE` scan plus a `COUNT` over what is typically the largest `shard_*` table, reachable with no session). `sort ∈ {price_asc, price_desc, recent}`. `q` matches the resolved display name **or** the item's literal name, with `%`/`_` escaped: they are `LIKE` metacharacters, not SQL ones, so parameterization alone would let `?q=%` match every listing on the shard. Every response repeats `staleAt` (the oldest vendor row) because the shard sweeps round-robin — a banner that ages with the results it labels, not one fetched once. |
| GET | `/shard/market/meta` | index size, staleness (`staleAt`/`freshAt`) and which facets and regions actually hold vendors, so a client builds its filters without running a search it will discard. |
| GET | `/shard/market/vendors/:serial` | one shop and its listings; `:serial` must match `/^0x[0-9A-Fa-f]{1,16}$/` or **400** before any query runs. **404** = 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, and distinguishing them would leak that a hidden vendor exists. `truncated` (with `total` exceeding `count`) means the shop holds more than the shard publishes per frame. |
| GET | `/shard/features` | the shard features **this caller** may reach plus the audience rung they resolved to (§6.5), so a client hides nav it can't follow. Reports only what the caller can see — the list itself never discloses a gated feature. Consumed by the SPA header and (pending) the Android nav. |
| GET | `/atlas/creatures?q=&facet=&limit=&offset=` | the bestiary, most numerous first, with an unpaginated `total`. Static content parsed from the shard's ServUO tree — **not** sidecar-backed, which is why the atlas sits outside `/shard`, and unlike `/shard/*` it **is** site-mode gated. Behind `requireFeature('atlas')`. `?facet=` is matched exactly and never validated against a list (no facet name exists in the code); the filter is an `EXISTS` over the points rather than a JSON path or `JSON_SEARCH` built from caller input, whose `%`/`_` wildcards would make `?facet=%` match everything. |
| GET | `/atlas/creatures/:slug` | one creature: `places` (the point-in-rect aggregate — "lizardman → Shrines, Isamu-Jima, Yew"), `spawners` (the bounded raw list, with `spawnersTruncated`), `alsoHere`. **`points` is a COUNT and `spawners` is the LIST** — named apart so one key never means a number on one route and an array on another. `minDelay`/`maxDelay` are in **seconds**, normalised at parse time from the source's per-record minutes-or-seconds. 404 = no such creature in this atlas. |
@@ -835,7 +882,11 @@ who"; `activity_log` provides the history feed.
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto``secure: req.secure`).
- **Trusted-device MFA.** A second, separate httpOnly cookie (`rg_trust`, default 30d) — opaque, sha256-hashed server-side in `trusted_devices` — lets a browser/app **skip the TOTP step** (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the **second factor**; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. **Recovery codes** (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (`auth.login.trusted_device`, `account.trusted_device.*`, `account.recovery_code*`, `admin.trusted_device.*`, `admin.user.totp.reset`) are audit-logged. See `docs/website/TRUSTED_DEVICES_MFA.md`. This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
- **Rate limiting** (`express-rate-limit`) on `/auth/login`, `/public/contact`, and — the only limited
*read*`/public/shard/market` and `/public/shard/market/vendors/:serial` (60/min/IP). Every other
public read is an indexed lookup of bounded size; the marketplace search is a `LIKE` scan plus a
`COUNT` over the largest `shard_*` table, anonymous by default, so it is the one public GET that is
worth money to serve.
- **Validation** (`express-validator`) on all writes; centralized error handler.
- **helmet** with a Content-Security-Policy tuned for the built React SPA. The policies now live in
**`server/src/config/csp.js`** (`app.js` only wires them up):

167
website/MARKETPLACE.md Normal file
View File

@@ -0,0 +1,167 @@
# 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=&region=&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.

View File

@@ -60,12 +60,32 @@ board while holding back one column. See the table in §3.
| **Shard rules** | Skill/stat caps, house limits, vet rewards, the ruleset | Everyone | Connect address → Everyone |
| **Spawn atlas** | Bestiary and spawn locations (static content) | Everyone | — |
| **Leaderboards** | Point and loyalty standings | Everyone | Character names → Everyone |
| **Marketplace** | The shard-wide player-vendor index | Everyone, **live updates off** | Vendor owner name → Everyone · Location → Everyone |
| **Marketplace** | The shard-wide player-vendor index | Everyone, **live updates off** | Vendor owner name → Everyone · Vendor owner character id → Everyone · In-game location → Everyone |
**Why the marketplace ships with live updates off.** A live feed of every vendor's full inventory
would be the single largest thing the site sends. No page needs it — the marketplace is a search over
stored data with a “prices last refreshed N minutes ago” stamp. Turn it on only if you want it.
**Why the marketplace's fields default to Everyone.** A vendor's shop name, its owner's character
name and where it is standing are *already* visible to every player in game: the stock Vendor Search
gump surfaces exactly that set to anyone who opens it. Publishing them on the site is not a new
disclosure. They stay configurable because a shard may still prefer to keep its economy behind a
login — and because "already public in game" is a judgement about your shard, not ours.
**Location is one setting covering four things.** Hiding it removes the facet, the coordinates, the
region *and* the house name together. That is deliberate: those are four ways of saying the same
thing, and a setting that hid the coordinates while publishing the house name would not have hidden
anything.
**Hiding the owner name also hides the owner character id.** They are separate settings so you can
be explicit, but leaving the id published while hiding the name achieves nothing — the leaderboards
and guild boards resolve that same id back to a character name. Set both.
**What hiding a vendor cannot do.** Only vendors whose owner left the in-game *Vendor Search* flag ON
are ever sent to the site, so a player who hides their shop in game is hidden here too — and no
setting on this page can override that. It works the other way as well: these settings control who
sees the index, not whether players can find each other's shops in game.
**Why house owner/price default to Staff.** The public Houses page has always been a "where are the
falling houses" board — location only. Owner and price are the staff view. That split is preserved.