feat(sidecar): store and serve the player-vendor market index
Protocol 3.0 §8. Ingests vendor.listing / vendor.listing.remove into a `vendors` table and serves GET /market. The frame is authoritative for one vendor, so the upsert is a whole-row overwrite. Unlike the other 3.0 boards there IS a remove: a vendor is dismissed, expires, or its owner switches off the in-game Vendor Search flag — the last of those is a privacy control, so dropping the row promptly is the point. Items ride inside the stored blob and are deliberately not normalized into a vendor_items table. The sidecar's job for the market is outage resilience (PROTOCOL_2.md §12.2), not search; search lives in MariaDB on the website side, where the query surface, the indexes and the cliloc-resolved names already are. /market is the only PAGED read the sidecar serves, because it is the only board that can be a whole world's inventory. limit clamps to 1..1000 (default 200) and `total` comes back so a caller knows when to stop rather than paging until it sees a short page, which would race a concurrent sweep. Ordering is by SERIAL, not shop name: a serial is stable while a shop name is renameable, so a rename mid-walk cannot make a vendor skip or repeat a page. The route is /market and not /vendors: /vendors/:account next door is the per-account RPC, and two routes a prefix apart meaning "this player's shops" and "every shop on the shard" is a readability trap. Frames are served verbatim, owner names and coordinates included — the sidecar defines no audiences (v3.md §3.2). Verified against the live shard: 27 vendors / 1,040 listings ingested from the plugin, plus a synthetic insert-then-remove confirming the delete path. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -371,6 +371,91 @@ impl Store {
|
||||
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
|
||||
}
|
||||
|
||||
// ---- player-vendor market index (Protocol 3.0) ----
|
||||
|
||||
/// Upserts one vendor's whole listing, keyed by serial. Fed from `vendor.listing`, which the
|
||||
/// shard emits as an authoritative per-vendor frame — so this replaces the row outright rather
|
||||
/// than merging anything.
|
||||
///
|
||||
/// The items ride inside `json` and are deliberately NOT normalized into a `vendor_items`
|
||||
/// table. The sidecar's job for the market is outage resilience (`PROTOCOL_2.md` §12.2) — hand
|
||||
/// the website back what the shard last said — not search. Search lives in MariaDB on the
|
||||
/// website side, where the query surface, the indexes and the cliloc-resolved display names
|
||||
/// already are; a second search implementation here would be one more thing to keep in step
|
||||
/// with it for no reader.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn upsert_vendor(
|
||||
&self,
|
||||
serial: &str,
|
||||
shop_name: Option<&str>,
|
||||
owner_name: Option<&str>,
|
||||
map: Option<&str>,
|
||||
x: Option<i64>,
|
||||
y: Option<i64>,
|
||||
region: Option<&str>,
|
||||
count: Option<i64>,
|
||||
json: &str,
|
||||
t: i64,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO vendors (serial, shop_name, owner_name, map, x, y, region, count, json, updated_t)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(serial) DO UPDATE SET shop_name = excluded.shop_name,
|
||||
owner_name = excluded.owner_name, map = excluded.map, x = excluded.x, y = excluded.y,
|
||||
region = excluded.region, count = excluded.count, json = excluded.json,
|
||||
updated_t = excluded.updated_t",
|
||||
)
|
||||
.bind(serial)
|
||||
.bind(shop_name)
|
||||
.bind(owner_name)
|
||||
.bind(map)
|
||||
.bind(x)
|
||||
.bind(y)
|
||||
.bind(region)
|
||||
.bind(count)
|
||||
.bind(json)
|
||||
.bind(t)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drops one vendor from the index. Fed from `vendor.listing.remove` — a vendor dismissed,
|
||||
/// expired, or whose owner switched off its in-game Vendor Search flag.
|
||||
pub async fn delete_vendor(&self, serial: &str) -> anyhow::Result<()> {
|
||||
sqlx::query("DELETE FROM vendors WHERE serial = ?")
|
||||
.bind(serial)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One page of the index, ordered by serial.
|
||||
///
|
||||
/// Paged where the other boards are not, and the ordering is why it can be: a whole-world
|
||||
/// market is the one board that does not fit in a response. Ordering by SERIAL rather than by
|
||||
/// shop name is deliberate — the page is a snapshot cursor for the website's reconnect
|
||||
/// backfill, and a serial is stable while a shop name is renameable, so a rename mid-backfill
|
||||
/// cannot make a vendor skip or repeat a page.
|
||||
pub async fn vendors_page(&self, limit: i64, offset: i64) -> anyhow::Result<Vec<Value>> {
|
||||
let limit = limit.clamp(1, 1000);
|
||||
let offset = offset.max(0);
|
||||
let rows = sqlx::query("SELECT json FROM vendors ORDER BY serial LIMIT ? OFFSET ?")
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(parse_json_column(rows))
|
||||
}
|
||||
|
||||
/// How many vendors the index holds, so a paging caller knows when to stop.
|
||||
pub async fn vendors_count(&self) -> anyhow::Result<i64> {
|
||||
let row = sqlx::query("SELECT COUNT(*) AS n FROM vendors")
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.get::<i64, _>("n"))
|
||||
}
|
||||
|
||||
// ---- Town Cryer news (Protocol 2.1) ----
|
||||
|
||||
/// Stores/replaces one external news article (the `news.add` command json), keyed by id. The
|
||||
@@ -480,6 +565,23 @@ CREATE TABLE IF NOT EXISTS points_boards (
|
||||
updated_t INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Player-vendor market index (Protocol 3.0). One row per vendor, holding the whole authoritative
|
||||
-- `vendor.listing` frame including its items. The hoisted columns exist for the ORDER BY and for
|
||||
-- an operator eyeballing the table; nothing here is searched, because search is the website's job
|
||||
-- (see upsert_vendor). Rows are dropped on `vendor.listing.remove`.
|
||||
CREATE TABLE IF NOT EXISTS vendors (
|
||||
serial TEXT PRIMARY KEY,
|
||||
shop_name TEXT,
|
||||
owner_name TEXT,
|
||||
map TEXT,
|
||||
x INTEGER,
|
||||
y INTEGER,
|
||||
region TEXT,
|
||||
count INTEGER,
|
||||
json TEXT NOT NULL,
|
||||
updated_t INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- The shard's published ruleset (Protocol 3.0). Singleton: the CHECK is what makes it one,
|
||||
-- so an upsert can target id = 1 unconditionally and no second row can ever appear.
|
||||
CREATE TABLE IF NOT EXISTS ruleset (
|
||||
|
||||
Reference in New Issue
Block a user