From 8e018a01e9c0b06152285cfad74b77d77aa30531 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 28 Jul 2026 11:19:10 -0500 Subject: [PATCH 1/4] feat(store): persist world.ruleset and serve it from GET /ruleset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol 3.0 §5 (docs/link/v3.md). The shard publishes one world.ruleset frame per connect describing how it is configured; the sidecar folds it into a singleton row and serves it back. Store-backed rather than an RPC, for the same reason /guilds and /houses are (PROTOCOL_2.md §12.2): a rules page that goes blank while the shard restarts is worse than one that is briefly stale. `{"ruleset": null}` distinguishes "the shard has never published one" — an old plugin, or Bridge.RulesetEnabled=false — from a published ruleset, which the website renders differently. `rev` (the shard's FNV-1a of the body) is kept alongside the JSON so a reader can tell "same ruleset, re-sent on reconnect" from "the operator changed something" without diffing. PROTOCOL_VERSION stays 2. The 2→3 bump is a hard operator-visible cutover and happens exactly once, at the end of v3 (§4), not per phase. Smoke-tested against a fake shard on loopback: frame ingested, GET /ruleset returns it with plugin_connected=false (outage path), and the route sits behind the gate (409 on a version mismatch, 401 unauthenticated). cargo build + clippy clean. Co-Authored-By: Claude --- sidecar/src/main.rs | 15 +++++++++++++++ sidecar/src/store.rs | 38 ++++++++++++++++++++++++++++++++++++++ sidecar/src/web.rs | 21 +++++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index c6ce727..2bf1be5 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -194,6 +194,21 @@ async fn main() -> anyhow::Result<()> { } } } + // 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. + "world.ruleset" => { + if let Err(e) = event_store + .upsert_ruleset( + ev.value.get("rev").and_then(|r| r.as_str()), + &text, + t, + ) + .await + { + tracing::warn!(error = %e, "failed to upsert ruleset"); + } + } _ => {} } } diff --git a/sidecar/src/store.rs b/sidecar/src/store.rs index 58f42cb..dcdb9d9 100644 --- a/sidecar/src/store.rs +++ b/sidecar/src/store.rs @@ -293,6 +293,35 @@ impl Store { Ok(parse_json_column(rows)) } + // ---- shard ruleset (Protocol 3.0) ---- + + /// Stores the shard's published ruleset. A singleton (`id = 1`): the shard emits one + /// `world.ruleset` frame per connect describing how it is configured, and only the latest one + /// matters. `rev` is the shard's FNV-1a of the body, kept so a reader can tell "same ruleset, + /// re-sent on reconnect" from "the operator changed something" without diffing the JSON. + pub async fn upsert_ruleset(&self, rev: Option<&str>, json: &str, t: i64) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO ruleset (id, rev, json, updated_t) VALUES (1, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET rev = excluded.rev, json = excluded.json, updated_t = excluded.updated_t", + ) + .bind(rev) + .bind(json) + .bind(t) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// The stored ruleset, or `None` if the shard has never published one. Returning `None` rather + /// than an empty object is deliberate: "not published yet" and "published, everything off" are + /// different answers and the website renders them differently. + pub async fn ruleset(&self) -> anyhow::Result> { + let row = sqlx::query("SELECT json FROM ruleset WHERE id = 1") + .fetch_optional(&self.pool) + .await?; + Ok(row.and_then(|r| serde_json::from_str(&r.get::("json")).ok())) + } + // ---- Town Cryer news (Protocol 2.1) ---- /// Stores/replaces one external news article (the `news.add` command json), keyed by id. The @@ -392,4 +421,13 @@ CREATE TABLE IF NOT EXISTS news ( 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 ( + id INTEGER PRIMARY KEY CHECK (id = 1), + rev TEXT, + json TEXT NOT NULL, + updated_t INTEGER NOT NULL +); "#; diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index b37b262..5f96b96 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -82,6 +82,10 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/governors", get(governors)) .route("/online", get(online)) .route("/houses", get(houses)) + // The shard ruleset (Protocol 3.0), likewise store-backed: the shard publishes it once per + // connect, so serving it from the store is what lets the site's rules page render while the + // shard is down. + .route("/ruleset", get(ruleset)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -790,6 +794,23 @@ async fn houses(State(st): State) -> impl IntoResponse { } } +/// The shard's published ruleset: expansion, which optional systems are on, skill/stat caps, +/// account and house limits, champion scroll rules, the save/restart schedule. Served from the +/// store, so it answers during a shard outage with the last-known ruleset — which is the whole +/// point, since a rules page that goes blank when the shard restarts is worse than a stale one. +/// +/// `{"ruleset": null}` means the shard has never published one (an old plugin, or +/// `Bridge.RulesetEnabled=false`), which the website renders differently from a published ruleset. +async fn ruleset(State(st): State) -> impl IntoResponse { + match st.store.ruleset().await { + Ok(r) => (StatusCode::OK, Json(json!({ "ruleset": r }))), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ), + } +} + /// The current online population: total plus per-facet and per-region counts. This is the most /// recent `presence.online` snapshot from the event store (so it survives a sidecar restart); the /// live `presence.online` stream keeps it current, and `GET /history?kind=presence.online` gives the From 21a1462e620724ff64c4527505d73e524d0d4a54 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 28 Jul 2026 21:04:23 -0500 Subject: [PATCH 2/4] feat(store): persist points.board and serve it from GET /points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol 3.0 §7 (docs/link/v3.md). The shard publishes ~25 points/loyalty leaderboards as one points.board frame per system; the sidecar folds each into a projection table and serves them back, so the site's leaderboards page renders during a shard outage. - points_boards(system PK, name, json, updated_t), keyed by the shard's own PointsType name. `name` is hoisted only for the ORDER BY. - main.rs gains a points.board arm keyed on `system`, alongside the existing champ/guild/governor/house/ruleset projections. There is deliberately no delete counterpart: the shard's set of point systems is fixed at startup, so it emits no points.remove — the same shape the governor board already has. - GET /points returns every board ordered by display name; GET /points/:system returns one, or 404 when the shard has never published that system. 404 and "a published board nobody has scored in yet" (200, empty top) are different answers, and the website renders them differently. Store-backed rather than an RPC for the same reason as the other boards, and it matters more here: these are standings accumulated over months, so blanking them during a shard restart reads as data loss rather than as staleness. PROTOCOL_VERSION stays at 2 — the bump to 3 is the one-time edge → main cutover in v3.md §4, not a per-phase change. cargo build and cargo clippy --all-targets are clean. Smoke-tested against a driver on the loopback link: two boards stored and served, a re-emitted system overwriting rather than accumulating, 404 for an unknown system, 401 unauthenticated. Also verified against the real ServUO shard, which fed five live boards through this path. Co-Authored-By: Claude --- sidecar/src/main.rs | 20 +++++++++++++++ sidecar/src/store.rs | 58 ++++++++++++++++++++++++++++++++++++++++++++ sidecar/src/web.rs | 41 +++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 2bf1be5..6949b6a 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -194,6 +194,26 @@ async fn main() -> anyhow::Result<()> { } } } + // Points/loyalty boards (Protocol 3.0): one row per point system, keyed by + // the shard's own PointsType name. The plugin only emits a system whose top N + // actually moved, so this is a sparse stream of overwrites — and there is no + // `points.remove` to handle, because the shard's set of systems is fixed at + // startup and cannot shrink. + "points.board" => { + if let Some(system) = ev.value.get("system").and_then(|s| s.as_str()) { + if let Err(e) = event_store + .upsert_points_board( + system, + ev.value.get("nameString").and_then(|n| n.as_str()), + &text, + t, + ) + .await + { + tracing::warn!(error = %e, "failed to upsert points board"); + } + } + } // 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 dcdb9d9..cba9f4a 100644 --- a/sidecar/src/store.rs +++ b/sidecar/src/store.rs @@ -322,6 +322,55 @@ impl Store { Ok(row.and_then(|r| serde_json::from_str(&r.get::("json")).ok())) } + // ---- points / loyalty boards (Protocol 3.0) ---- + + /// Upserts one system's leaderboard, keyed by its `PointsType` name (`QueensLoyalty`, + /// `CleanUpBritannia`, …). Fed from `points.board`; one row per system, always the most recent + /// top-N snapshot. + /// + /// There is no matching delete, and that is deliberate rather than an omission: the shard's set + /// of point systems is fixed at startup by `PointsSystem.Configure`, so a system cannot vanish + /// at runtime and the plugin emits no `points.remove`. Same argument the governor board makes. + pub async fn upsert_points_board( + &self, + system: &str, + name: Option<&str>, + json: &str, + t: i64, + ) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO points_boards (system, name, json, updated_t) VALUES (?, ?, ?, ?) + ON CONFLICT(system) DO UPDATE SET name = excluded.name, json = excluded.json, updated_t = excluded.updated_t", + ) + .bind(system) + .bind(name) + .bind(json) + .bind(t) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Every system's latest board, ordered by display name then system key. Systems the shard has + /// never published are simply absent — the website renders the set it is given. + pub async fn points_boards_all(&self) -> anyhow::Result> { + let rows = sqlx::query("SELECT json FROM points_boards ORDER BY name, system") + .fetch_all(&self.pool) + .await?; + Ok(parse_json_column(rows)) + } + + /// One system's board, or `None` when that system has never published one. `None` is a real + /// answer (an unknown system name, or one the operator excluded via `Bridge.cfg PointsSystems`), + /// which the website turns into a 404 rather than an empty board. + pub async fn points_board(&self, system: &str) -> anyhow::Result> { + let row = sqlx::query("SELECT json FROM points_boards WHERE system = ?") + .bind(system) + .fetch_optional(&self.pool) + .await?; + Ok(row.and_then(|r| serde_json::from_str(&r.get::("json")).ok())) + } + // ---- Town Cryer news (Protocol 2.1) ---- /// Stores/replaces one external news article (the `news.add` command json), keyed by id. The @@ -422,6 +471,15 @@ CREATE TABLE IF NOT EXISTS news ( updated_t INTEGER NOT NULL ); +-- Points/loyalty leaderboards (Protocol 3.0). One row per point system, keyed by the shard's +-- own PointsType name; `name` is the resolved display name, hoisted only for the ORDER BY. +CREATE TABLE IF NOT EXISTS points_boards ( + system TEXT PRIMARY KEY, + name TEXT, + 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 5f96b96..3912ef1 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -86,6 +86,10 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // connect, so serving it from the store is what lets the site's rules page render while the // shard is down. .route("/ruleset", get(ruleset)) + // Points/loyalty leaderboards (Protocol 3.0), store-backed like the other boards: the + // whole set, or one system by its PointsType name. + .route("/points", get(points)) + .route("/points/:system", get(points_system)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -811,6 +815,43 @@ async fn ruleset(State(st): State) -> impl IntoResponse { } } +/// Every points/loyalty leaderboard the shard publishes: one entry per point system, each with its +/// display name (literal and/or cliloc), max points, participant count and top N. Store-backed like +/// the other boards, so the site's leaderboards page renders during a shard outage — which matters +/// more here than elsewhere, since these are month-scale standings that a restart must not blank. +async fn points(State(st): State) -> impl IntoResponse { + match st.store.points_boards_all().await { + Ok(boards) => (StatusCode::OK, Json(json!({"boards": boards}))), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ), + } +} + +/// One system's board by its `PointsType` name (`QueensLoyalty`, `CleanUpBritannia`, …). +/// +/// 404 rather than an empty board when the system is unknown: the shard publishes only the systems +/// it shows on the loyalty gump (or the explicit `Bridge.cfg PointsSystems` list), so "no such +/// board" and "a board with nobody on it" are different answers and the website renders them +/// differently. +async fn points_system( + State(st): State, + Path(system): Path, +) -> impl IntoResponse { + match st.store.points_board(&system).await { + Ok(Some(board)) => (StatusCode::OK, Json(board)), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "unknown points system", "system": system})), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ), + } +} + /// The current online population: total plus per-facet and per-region counts. This is the most /// recent `presence.online` snapshot from the event store (so it survives a sidecar restart); the /// live `presence.online` stream keeps it current, and `GET /history?kind=presence.online` gives the From 480423090ab21272c054a2dd308587e050b61684 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 29 Jul 2026 09:51:14 -0500 Subject: [PATCH 3/4] 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 { From 5f50b881ca26b639ea4393734d09510493121295 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 29 Jul 2026 18:03:31 -0500 Subject: [PATCH 4/4] feat(sidecar)!: bump PROTOCOL_VERSION to 3 Protocol 3.0 is feature-complete on `edge` -- world.ruleset, points.board and vendor.listing / vendor.listing.remove all landed there while the sidecar kept declaring 2, because a bump is an operator-visible hard break (409 on every protected route via web.rs::gate, and the website closes the WS on the ws.hello mismatch). Doing it per phase would have broken the site four times; this is the one time it happens. Nothing that existed in v2 changed shape, so the version constant and its doc comment are the whole change here. The README's worked example moves with it -- it still claimed "currently 1", two bumps stale. Verified against the release binary: /health reports "protocol": 3, every response carries `X-UOLink-Version: 3`, an authenticated request declaring 2 is refused 409 {"sidecar_protocol":3,"client_protocol":"2"}, and one declaring 3 gets 200 off /ruleset. cargo build --release + cargo clippy --all-targets clean. Co-Authored-By: Claude --- sidecar/README.md | 6 +++--- sidecar/src/main.rs | 7 ++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/sidecar/README.md b/sidecar/README.md index cb5a1af..bd774fe 100644 --- a/sidecar/README.md +++ b/sidecar/README.md @@ -41,10 +41,10 @@ So you can never accidentally run without auth. Rotate by editing the token and ## Protocol version -The wire protocol has a version (`PROTOCOL_VERSION`, currently **1**), so the website and sidecar detect a mismatch immediately instead of failing in strange ways when a message shape changes. +The wire protocol has a version (`PROTOCOL_VERSION`, currently **3**), so the website and sidecar detect a mismatch immediately instead of failing in strange ways when a message shape changes. -- Every response carries an `X-UOLink-Version: 1` header. -- `/health` and the WebSocket `ws.hello` include `"protocol": 1`. +- Every response carries an `X-UOLink-Version: 3` header. +- `/health` and the WebSocket `ws.hello` include `"protocol": 3`. - If a request sends `X-UOLink-Version` and it disagrees with the sidecar, the request is rejected **409 Conflict** with `{sidecar_protocol, client_protocol}` so the mismatch is obvious. Bump `PROTOCOL_VERSION` in `main.rs` whenever an event or endpoint's shape changes. diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index c9cc854..1a5a659 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -24,7 +24,12 @@ use tracing_subscriber::EnvFilter; /// v2 (Protocol 2.0): adds the account-provisioning verbs/endpoints (`POST /accounts/create`, /// `DELETE /link/:account`) and their events. Outbound event kinds are additive, so a v1 website /// keeps working against the live feed; the new *endpoints* require a v2 sidecar. -pub const PROTOCOL_VERSION: u32 = 2; +/// +/// v3 (Protocol 3.0): adds `world.ruleset`, `points.board` and `vendor.listing` / +/// `vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them +/// from the store. Same shape as the v2 bump — the kinds are additive, the endpoints are not — and +/// there is deliberately no feature-negotiation array: v3 implies all three kinds. +pub const PROTOCOL_VERSION: u32 = 3; #[tokio::main] async fn main() -> anyhow::Result<()> {