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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user