From 480423090ab21272c054a2dd308587e050b61684 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 29 Jul 2026 09:51:14 -0500 Subject: [PATCH] feat(sidecar): store and serve the player-vendor market index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- sidecar/src/main.rs | 38 ++++++++++++++++ sidecar/src/store.rs | 102 +++++++++++++++++++++++++++++++++++++++++++ sidecar/src/web.rs | 54 +++++++++++++++++++++++ 3 files changed, 194 insertions(+) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 6949b6a..c9cc854 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -214,6 +214,44 @@ async fn main() -> anyhow::Result<()> { } } } + // Player-vendor market index (Protocol 3.0). Each frame is authoritative for + // one vendor — the shard's round-robin sweep only emits a shop whose contents, + // prices or location actually moved — so this is a whole-row overwrite. + // + // Unlike the boards above there IS a remove: a vendor is dismissed, expires, or + // its owner switches off the in-game Vendor Search flag, and any of those must + // take the shop off the site. The last of the three is a privacy control, so + // dropping the row promptly is the point rather than housekeeping. + "vendor.listing" => { + if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) { + let loc = ev.value.get("location"); + let field = |k: &str| loc.and_then(|l| l.get(k)); + if let Err(e) = event_store + .upsert_vendor( + serial, + ev.value.get("shopName").and_then(|v| v.as_str()), + ev.value.get("ownerName").and_then(|v| v.as_str()), + field("map").and_then(|v| v.as_str()), + field("x").and_then(|v| v.as_i64()), + field("y").and_then(|v| v.as_i64()), + field("region").and_then(|v| v.as_str()), + ev.value.get("count").and_then(|v| v.as_i64()), + &text, + t, + ) + .await + { + tracing::warn!(error = %e, "failed to upsert vendor listing"); + } + } + } + "vendor.listing.remove" => { + if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) { + if let Err(e) = event_store.delete_vendor(serial).await { + tracing::warn!(error = %e, "failed to remove vendor listing"); + } + } + } // Shard ruleset (Protocol 3.0): a singleton projection. The shard re-emits // world.ruleset on every connect, so this row is simply overwritten; `rev` // lets a reader tell a re-send from an actual config change. diff --git a/sidecar/src/store.rs b/sidecar/src/store.rs index cba9f4a..d1e55cc 100644 --- a/sidecar/src/store.rs +++ b/sidecar/src/store.rs @@ -371,6 +371,91 @@ impl Store { Ok(row.and_then(|r| serde_json::from_str(&r.get::("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, + y: Option, + region: Option<&str>, + count: Option, + 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> { + 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 { + let row = sqlx::query("SELECT COUNT(*) AS n FROM vendors") + .fetch_one(&self.pool) + .await?; + Ok(row.get::("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 ( diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 3912ef1..978716e 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -90,6 +90,12 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // whole set, or one system by its PointsType name. .route("/points", get(points)) .route("/points/:system", get(points_system)) + // The player-vendor market index (Protocol 3.0). `/market`, NOT `/vendors`: axum would + // route the latter fine, but `/vendors/:account` next door is the per-account RPC, and two + // routes a prefix apart that mean "this player's shops" and "every shop on the shard" is a + // readability trap nobody wins. The only PAGED read the sidecar serves — a whole-world + // market does not fit in one response. + .route("/market", get(market)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -872,6 +878,54 @@ async fn online(State(st): State) -> impl IntoResponse { } } +#[derive(Deserialize)] +struct PageQuery { + limit: Option, + offset: Option, +} + +/// The player-vendor market index: every vendor's shop name, owner, location and priced inventory, +/// as the shard last published it. Store-backed like the other boards, which is what lets the +/// website's market page render (labelled stale) while the shard is down. +/// +/// Paged — `?limit=&offset=`, limit clamped to 1..1000, default 200 — because this is the one board +/// that can be a whole world's inventory. `total` is returned alongside so the caller knows when to +/// stop rather than paging until it sees a short page, which would race a concurrent sweep. +/// +/// The frames are served VERBATIM, including owner names and coordinates. That is not an oversight: +/// the sidecar defines no audiences (docs/link/v3.md §3.2). Deciding who may see a vendor's owner +/// or whereabouts is the website's job and is admin-configurable there. +async fn market(State(st): State, Query(q): Query) -> impl IntoResponse { + let limit = q.limit.unwrap_or(200); + let offset = q.offset.unwrap_or(0); + + let total = match st.store.vendors_count().await { + Ok(n) => n, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ) + } + }; + + match st.store.vendors_page(limit, offset).await { + Ok(vendors) => ( + StatusCode::OK, + Json(json!({ + "vendors": vendors, + "total": total, + "limit": limit.clamp(1, 1000), + "offset": offset.max(0), + })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ), + } +} + // ---- websocket ---- async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State) -> impl IntoResponse { -- 2.49.1