feat(shard): the player-vendor marketplace #116
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/vendor-listing"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What & why
Protocol 3.0 §8 (
docs/link/v3.md) — order 5b, the website side, and the last part before the cutover. Plugin in servuo-plugins #5; sidecar in link #19; docs in docs #71.The shard-wide shop index: what every player vendor is selling, for how much, and where it is standing — the same set the in-game Vendor Search gump reads, offered from outside the game.
/site/marketand/site/market/vendors/:serial.This is also the payoff for #115: item names come from the cliloc table, so listings read "longsword" rather than "id 3937".
⚠️ Part A's pre-wired visibility rules, re-checked against the real frame
Part A pre-wired
market.ownerNameandmarket.locationbefore the frame existed. The sibling rule it pre-wired for leaderboards —characterName— turned out to be inert, becauseprojectValuematches literal JSON keys and the wire key isname. So both market rules were checked this time rather than trusted:ownerNameis a real key on the frame. Kept.locationis a real key only because the frame nests it. §8.1's payload sketch had flatmap/x/y/region, which would have made the rule match nothing — the same failure, one part later. It is nested on the wire and on the read model so one rule hides the facet, the coordinates, the region and the house together; five flat keys would be five rules that drift apart.ownerSerialwas ADDED. An admin who hides the owner's name and leaves a serial that the leaderboards and guild boards resolve straight back to that name has not hidden anything.Tests assert all three bite on the stored read model and on the raw frame. The market's SSE stream is off by default, but an admin can turn it on, and a field rule that worked on only one of the two paths is exactly the leak §3.6.1 records.
Three things the pages must say out loud
All consequences of how the data is gathered, and each has a visible UI obligation:
staleAtrides on every search response rather than sitting behind a separate call, so the stamp ages with the results it labels.totalexceedingcountmeans the shop holds more than the shard publishes per frame. The vendor page says "showing 250 of 3,104"; a commodity reseller with thousands of stacks is real.displayNameis null and the honest render is the item id — never a fabricated "Item 3922", which would be indistinguishable from a real name.Notable
payloadcolumn onshard_vendors, unlikeshard_points_boardsnext 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 nothing is left worth duplicating. The sidecar keeps the whole blob — outage resilience is its job, search is ours.display_nameis denormalized at ingest, resolved from the item's literalname(preferred — a player set it, so it is more specific) else its cliloc. Resolving at query time would put the cliloc table on the hot path and make search-by-name impossible.updated_atis written explicitly on every upsert. MariaDB does not fireON UPDATE CURRENT_TIMESTAMPwhen every column is written back unchanged — and a shop re-published identically is still freshly confirmed. Without this the staleness banner would age a perfectly current shop forever.qare escaped.%and_areLIKEmetacharacters, not SQL ones, so parameterization does not neutralize them:?q=%would otherwise match every listing on the shard. (The atlas'sLIKEsearches predate this and have the same shape over a much smaller table — worth a follow-up, not a blocker here.)LIKEscan plus aCOUNTover what is typically the largestshard_*table, anonymous by default./market, bounded byMARKET_SNAPSHOT_MAX = 5000and stopping on a short page as well as ontotal, so a concurrent sweep shrinking the index cannot spin the walk. Backfill runs before the site serves the live feed, so an unbounded loop there is downtime, not slowness.How it was tested
673 server tests pass (27 new:
shardIngest.market.test.jsfor routing,shardMarket.model.test.jsfor the flatten/shape rules and the visibility projection). Client builds clean.swagger-output.json,routes.manifest.jsonandroutes.guards.jsonregenerated and committed.Verified full-stack against the live MariaDB and a real shard, not only in units — plugin → Rust sidecar → website → API:
?q=sword→ 682,?q=long→ 352,?q=%→ 0 and?q=_→ 0 (the escape). Price range, facet, region, all three sorts, paging, and the vendor detail route.staffvanish for an anonymous caller whileshopNameandpricesurvive;audience=player→ 403;enabled=0→ 404; and/shard/featurescorrectly dropsmarket, so the nav hides the link rather than rendering one that 403s.?sort=bogus→ 400,?limit=999→ 400,/market/vendors/0xNOPE!→ 400, unknown serial → 404.updated_at; removing a vendor leaves zero orphan rows.Not covered by an automated test: the two React pages are presentational and this repo's client suite covers pure-logic modules only (there is no DOM harness). They were driven against the live API above, but not rendered in one.
CI note:
pr-checks.ymlonly triggers on PRs intomain, so this PR (targetingedge) runs no CI — local verification above is the gate.Checklist
AI-assisted contributions (required)
Claude Code (Opus 5). I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with aCo-Authored-Bytrailer.License
Protocol 3.0 §8, the website half. Ingests vendor.listing / vendor.listing.remove into shard_vendors + shard_vendor_items, serves a searchable public API over them, and ships /site/market and /site/market/vendors/:serial. Three things the pages have to say out loud, all consequences of how the data is gathered: - The prices are NOT live. The shard sweeps vendors round-robin, so a shop can be a full cycle behind. The banner is driven by the OLDEST vendor row, not the newest — the one stale shop is the one that wastes somebody's trip. - A shop can be truncated. `total` exceeding `count` means the shop holds more than the shard publishes per frame; the vendor page says "showing 250 of 3,104" rather than presenting a partial shop as complete. - An item may have no name. On a shard with no cliloc table the honest render is the item id, never an invented label. ## The pre-wired visibility rules, re-checked Part A pre-wired market.ownerName and market.location before the frame existed, and the sibling rule it pre-wired for leaderboards (`characterName`) turned out to be INERT because projectValue matches literal JSON keys. Both market rules were checked against the real frame this time: - `ownerName` is a real key. Kept. - `location` is a real key ONLY because the frame nests it. Flat map/x/y/region would have made the rule match nothing — the same failure, one part later. It is nested on the wire and on the read model so one rule hides the facet, the coordinates, the region and the house together; five flat keys would be five rules that drift apart. - `ownerSerial` was ADDED. An admin who hides the owner's name and leaves a serial that the leaderboards and guild boards resolve back to that same name has not hidden anything. Tests assert all three bite, on the stored read model AND on the raw frame — the market's SSE stream is off by default but an admin can turn it on, and a rule that worked on only one path is exactly the leak §3.6.1 records. ## Notable - **No payload column on shard_vendors**, unlike shard_points_boards next door. The board's top-N is a fixed-size list read whole; here the items ARE the searchable rows, so they are normalized and nothing is left worth duplicating. - **display_name is denormalized at ingest** (literal name preferred over the cliloc — a player set it, so it is more specific). 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 now triggers a bulk re-resolution — 50 ms per thousand rows, never throws. - **updated_at is written explicitly** on every upsert. MariaDB does not fire ON UPDATE CURRENT_TIMESTAMP when every column is written back unchanged, and a shop re-published identically is still freshly confirmed — without this the staleness banner would age a perfectly current shop forever. - **LIKE wildcards in `q` are escaped.** `%` and `_` are LIKE metacharacters, not SQL ones, so parameterization does not neutralize them: `?q=%` would otherwise match every listing on the shard. - **Rate-limited** (60/min/IP), the only limited public read. Every other public GET is an indexed lookup of bounded size; this is a LIKE scan plus a COUNT over the largest shard_* table, anonymous by default. - Reconnect backfill pages /market, bounded by MARKET_SNAPSHOT_MAX = 5000 and stopping on a short page as well as on `total`, so a concurrent sweep shrinking the index cannot spin the walk. ## How it was tested 673 server tests pass (27 new). Client builds clean; swagger-output.json, routes.manifest.json and routes.guards.json regenerated. Verified full-stack against the live MariaDB and a real shard, not only units: - 27 real vendors / 1,040 listings swept off the ServUO tree, through the Rust sidecar, into the site — names resolving through the cliloc table ("longsword", "katana"), real facets and regions in the filters. - `?q=sword` 682, `?q=%` and `?q=_` **0** (the escape), map/region/price/sort filters, paging, and the vendor detail route. - Visibility live: fields gated to staff vanish for an anonymous caller while shopName and price survive; audience=player 403s; enabled=0 404s; and /shard/features correctly drops `market` so the nav hides it. - Re-publishing a shop smaller leaves no orphan items; an identical re-publish moves updated_at. - The limiter fires (38x200 then 32x429 on a 70-request burst). Not covered by an automated test: the two React pages are presentational and this repo's client suite covers pure-logic modules only. They were driven against the live API above, but not rendered in a DOM harness. Co-Authored-By: Claude <noreply@anthropic.com>