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

@@ -15,8 +15,8 @@ Each part is marked off here as it lands on `edge`. §9 carries the same state p
| 2 | **B/1**`world.ruleset` (§5) | ✅ **Done** | servuo-plugins [#3](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/3), link [#17](https://gitea.whitlocktech.com/RunicGateway/link/pulls/17), website [#111](https://gitea.whitlocktech.com/RunicGateway/website/pulls/111), docs [#66](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/66) |
| 3 | **C** — spawn atlas (§6) | ✅ **Done** | website [#112](https://gitea.whitlocktech.com/RunicGateway/website/pulls/112) (parsers + CLI + tables) + [#113](https://gitea.whitlocktech.com/RunicGateway/website/pulls/113) (API + pages + admin panel), docs [#67](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/67) + [#68](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/68) |
| 4 | **B/2**`points.board` (§7) | ✅ **Done** | servuo-plugins [#4](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/4), link [#18](https://gitea.whitlocktech.com/RunicGateway/link/pulls/18), website [#114](https://gitea.whitlocktech.com/RunicGateway/website/pulls/114), docs [#69](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/69) |
| 5a | **B/3 dependency** — cliloc table (§8.6) | 🟨 In review | website [#115](https://gitea.whitlocktech.com/RunicGateway/website/pulls/115), docs [#70](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/70) |
| 5b | **B/3**`vendor.listing` (§8) | ⬜ Not started | — |
| 5a | **B/3 dependency** — cliloc table (§8.6) | **Done** | website [#115](https://gitea.whitlocktech.com/RunicGateway/website/pulls/115), docs [#70](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/70) |
| 5b | **B/3**`vendor.listing` (§8) | 🟨 In review | 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), docs [#71](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/71) |
| 6 | **Cutover**`PROTOCOL_VERSION` 2→3 (§4) | ⬜ Not started | — |
Order 5 split in two once §8.6's cliloc dependency turned out to be a client-format problem rather
@@ -639,7 +639,7 @@ if it is renamed back.
---
## 8. Part B/3 — `vendor.listing`
## 8. Part B/3 — `vendor.listing` 🟨 In review
### 8.1 It cannot be an RPC, and this is load-bearing
@@ -807,6 +807,75 @@ and text paths converge on identical content.
driven by `staleAt` (the oldest `shard_vendors.updated_at`). The round-robin sweep means data is
inherently up to one full cycle old, and the UI must say so.
Shipped with a second page, `routes/public/MarketVendor.jsx` at `/site/market/vendors/:serial`
where a search result points. It is the only surface that can render the two states the result list
cannot: a `truncated` shop (*"showing 250 of 3,104 — this shop holds more than the shard
publishes"*) and a `location` an admin has gated away, which is a real answer rather than an empty
coordinate.
### 8.8 What the build changed
Four things the implementation settled differently from §8 as written, all of them found by building
against the live shard.
**1. `location` is a nested object, not flat `map`/`x`/`y`/`region`.** §8.1's payload sketch had them
flat, and it would have made `market.location` — a rule Part A pre-wired — **inert**, exactly like
the `characterName` miss §7.5 records: `projectValue` matches literal JSON keys, so there is no
`location` key for the rule to match. Flat keys would have needed five rules that could drift apart.
Nesting makes one rule hide the facet, the coordinates, the region and the house together, on the
live frame and the stored read model alike, because both now spell it the same way.
The other pre-wired rule, `market.ownerName`, checked out — it is a real key on the frame. Owner is
written as flat `ownerSerial`/`ownerName` rather than through `BridgeJson.Actor`, which would add
`acct` and `webId`; same argument `points.board` makes. `ownerSerial` was **added** to the
configurable fields alongside `ownerName`, because an admin who hides the owner's name and leaves a
serial every other board resolves back to that name has not hidden anything.
**2. The per-vendor diff signature is the full listing set, not §8.3's `count | Σ(serial ^ price)`.**
That hash collides on the single most common change a shop makes: two items swapping prices, which
is what re-pricing looks like. The signature is built over the same buffer the frame is written
from, in the same order, so a match really does mean an identical frame.
**3. There is no `payload` column on `shard_vendors`.** §8.5 implied the board pattern (whole frame
in JSON, columns hoisted for display). It does not apply here: the items ARE the searchable rows, so
they are normalized into `shard_vendor_items` and there is nothing left worth duplicating. The
sidecar keeps the whole blob, because outage resilience is its job and search is not.
**4. Sweep cost is reported, and a slow tick warns.** The batch cap is a *claim* about per-tick cost,
and an operator tuning `MarketSweepBatch` was otherwise tuning blind. `[bridge status` now carries
`lastMs`/`maxMs`, and a tick over 50 ms prints a rate-limited warning naming the knob.
Measured on the live shard (27 vendors × 40 listings, 209k items / 43k mobiles):
| | |
|---|---|
| First tick — 25 vendors emitted cold | **15.4 ms** |
| Second tick — the remaining 2 | **3.4 ms** |
| Steady state — nothing changed | **0.3 ms** |
| Website `/market` search over 1,040 listings | 1,040 total, names resolved |
| Cliloc re-resolution pass over 1,040 rows | **50 ms** |
The diff is what makes the steady state ~free; the batch cap is what bounds the cold case. Note the
arithmetic the warning exists for: at the default cap of 250 listings, a batch of 25 **full** shops
is 6,250 items ≈ 95 ms — over budget. Real shops hold tens, which is why 25 is the default, but a
shard of commodity resellers should lower the batch, and now it will be told to.
Two smaller things worth not rediscovering:
- **`BridgeJson.Escape` takes a NON-NULL string** — it dereferences `value.Length` immediately — and
`BridgeJson.Str` writes its own `,"key":` prefix, so neither serves a value inside a hand-built
object. Nearly everything this frame writes is legitimately null (an item's plain `Name` is null
for almost every item; a vendor in the street has no house), so that is the common path, not an
edge case. `BridgeMarket.Text()` is the two-line writer that was missing.
- **The ServUO console writes in the OS code page**, so an em dash in a `Console.WriteLine` renders
as `???` in the log an operator would paste into an issue. Bridge console output is ASCII.
Search-side, one thing the site had to fix rather than inherit: `%` and `_` in a user's query are
**LIKE** metacharacters, not SQL ones, so parameterization does not neutralize them — a search for
`%` would otherwise match every listing on the shard. `shardMarket.db.js` escapes them. (The atlas's
`LIKE` searches predate this and have the same shape over a much smaller table; worth a follow-up,
not a blocker here.)
---
## 9. Sequencing
@@ -817,8 +886,8 @@ inherently up to one full cycle old, and the UI must say so.
| 2 | **B/1**`world.ruleset` (§5) | all four | new kind | ✅ Done |
| 3 | **C** — spawn atlas (§6) | website, docs | none | ✅ Done |
| 4 | **B/2**`points.board` (§7) | all four | new kind + `char.profile` field | ✅ Done |
| 5a | **B/3 dependency** — cliloc table (§8.6) | website, docs | none | 🟨 In review |
| 5b | **B/3**`vendor.listing` (§8) | all four | new kinds | |
| 5a | **B/3 dependency** — cliloc table (§8.6) | website, docs | none | ✅ Done |
| 5b | **B/3**`vendor.listing` (§8) | all four | new kinds | 🟨 In review |
| 6 | **Cutover**`PROTOCOL_VERSION` 2→3, `edge``main` | all four | the bump | ⬜ |
---