feat(shard): the player-vendor marketplace
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>
This commit is contained in:
@@ -640,6 +640,76 @@ CREATE TABLE IF NOT EXISTS shard_points_boards (
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Player-vendor market index (Protocol 3.0 vendor.listing). One row per player
|
||||
-- vendor and one per priced listing, so the site can offer the search the in-game
|
||||
-- Vendor Search gump offers — from outside the game.
|
||||
--
|
||||
-- The shard sweeps vendors round-robin and emits one AUTHORITATIVE frame per
|
||||
-- vendor, so ingest is delete-then-insert of that vendor's items inside one
|
||||
-- transaction (see shardMarket.db.js). No foreign key from items to vendors, 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.
|
||||
--
|
||||
-- Only vendors whose owner left the in-game Vendor Search flag ON are ever sent,
|
||||
-- so a player who hid their shop in game is hidden here too — see BridgeMarket.cs.
|
||||
CREATE TABLE IF NOT EXISTS shard_vendors (
|
||||
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- "0x40001234"
|
||||
shop_name VARCHAR(160) NULL,
|
||||
owner_serial VARCHAR(20) NULL,
|
||||
owner_name VARCHAR(64) NULL,
|
||||
map VARCHAR(40) NULL,
|
||||
x INT NULL,
|
||||
y INT NULL,
|
||||
z INT NULL,
|
||||
region VARCHAR(80) NULL,
|
||||
house VARCHAR(160) NULL, -- the house SIGN's name, not the house type
|
||||
item_count INT NOT NULL DEFAULT 0, -- listings published in the frame
|
||||
item_total INT NOT NULL DEFAULT 0, -- listings the shop actually holds
|
||||
truncated TINYINT(1) NOT NULL DEFAULT 0, -- item_total > item_count
|
||||
t BIGINT NULL, -- frame time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_vendors_owner (owner_name),
|
||||
INDEX idx_shard_vendors_map (map),
|
||||
INDEX idx_shard_vendors_region (region),
|
||||
-- The market page's staleness banner is MIN(updated_at) over this column: the
|
||||
-- round-robin sweep means the oldest row is how far behind the index can be.
|
||||
INDEX idx_shard_vendors_updated (updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- One priced listing. Unlike the points board's top-N — a fixed-size list read
|
||||
-- whole — these are the searchable rows the whole feature exists for, so they are
|
||||
-- normalized rather than left inside a payload column, and there is no payload
|
||||
-- column on shard_vendors at all.
|
||||
--
|
||||
-- `display_name` is DENORMALIZED at ingest: the shard sends `cliloc` (the item's
|
||||
-- LabelNumber) and, rarely, a literal `name`, and resolving 50 clilocs per page
|
||||
-- at query time would make the cliloc table a join on the hot path AND make
|
||||
-- search-by-name impossible. Resolving once on write buys the index. It is
|
||||
-- re-resolved in bulk after a cliloc import, because the diff sweep will not
|
||||
-- re-send an unchanged shop just because the site learned what its items are
|
||||
-- called.
|
||||
CREATE TABLE IF NOT EXISTS shard_vendor_items (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
vendor_serial VARCHAR(20) NOT NULL,
|
||||
serial VARCHAR(20) NOT NULL,
|
||||
item_id INT NOT NULL DEFAULT 0, -- ItemID (the art/graphic id)
|
||||
hue INT NOT NULL DEFAULT 0,
|
||||
amount INT NOT NULL DEFAULT 1,
|
||||
price BIGINT NOT NULL DEFAULT 0,
|
||||
name VARCHAR(160) NULL, -- the item's literal Name, null for most
|
||||
cliloc INT NULL, -- LabelNumber, resolved against shard_clilocs
|
||||
display_name VARCHAR(160) NULL, -- resolved at ingest; what search matches
|
||||
child TINYINT(1) NOT NULL DEFAULT 0, -- priced by an enclosing container, not itself
|
||||
INDEX idx_shard_vendor_items_vendor (vendor_serial),
|
||||
INDEX idx_shard_vendor_items_price (price),
|
||||
INDEX idx_shard_vendor_items_item (item_id),
|
||||
INDEX idx_shard_vendor_items_name (display_name),
|
||||
-- Search filters on name and sorts on price; the composite covers the common
|
||||
-- "cheapest matching X" without a filesort over the whole table.
|
||||
INDEX idx_shard_vendor_items_name_price (display_name, price)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Per-feature visibility for every shard-derived surface (Protocol 3.0). One row
|
||||
-- per feature; an absent row means "use the compiled default", and the compiled
|
||||
-- defaults reproduce the behavior that shipped before v3 — so an empty table is
|
||||
|
||||
Reference in New Issue
Block a user