Sidecar: REST query layer

rpc.rs bridges synchronous REST to the async shard stream. A call registers a
pending entry under a correlation id, sends the command, and awaits the reply
(10s timeout). The event loop routes any incoming line whose id is pending back
to the waiting caller; everything else stays a live event and is broadcast. Three
correlation fields are recognized, matching what the plugin echoes: reqId
(queries), code (link.confirm), id (towncrier).

web.rs adds the routes: GET /char/{account}/{slot}, /char/serial/{serial},
/roster/{account}, /vendors/{account}; POST /link/confirm, POST /towncrier,
DELETE /towncrier/{id}. A shard *.error reply maps to 404 or 400; no shard -> 503;
no reply in time -> 504.

Verified end to end against the live shard: roster and full char profile returned
as JSON (reqId correlation visible as r-1, r-2, ...), an unknown account returned
bridge.error as HTTP 404, vendor snapshot returned seed_000's two shops, towncrier
publish and remove returned towncrier.ok, and a bad link code returned link.error
as 404. The website can now query the game and push commands, all correlated over
the single loopback socket, all through the sidecar the game never directly
exposes.

Only SQLite persistence remains on the sidecar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 16:10:06 -05:00
parent aff10e846c
commit f93f47fad6
4 changed files with 271 additions and 28 deletions

View File

@@ -3,6 +3,7 @@
//! Terminates the loopback link to the ServUO shard and exposes a website-facing HTTP surface. So
//! far: the shard link (bidirectional) and a WebSocket live feed. REST queries and SQLite come next.
mod rpc;
mod shard;
mod web;
@@ -25,9 +26,14 @@ async fn main() -> anyhow::Result<()> {
// Live feed: every shard event fans out to all connected website WebSocket clients.
let (bcast_tx, _) = broadcast::channel::<String>(1024);
// Request/reply correlation for REST queries.
let rpc = rpc::Rpc::new();
// Website-facing HTTP server.
let web_state = web::AppState {
events: bcast_tx.clone(),
shard: handle.clone(),
rpc: rpc.clone(),
};
tokio::spawn(async move {
if let Err(e) = web::serve(WEB_ADDR, web_state).await {
@@ -35,11 +41,17 @@ async fn main() -> anyhow::Result<()> {
}
});
// Event loop: log, then broadcast. Later phases also persist to SQLite here.
// Event loop: a line that correlates to a pending REST call is a reply — route it to the
// waiting caller and stop. Everything else is a live event: log it and broadcast it.
let feed_tx = bcast_tx.clone();
let route_rpc = rpc.clone();
let mut total: u64 = 0;
tokio::spawn(async move {
while let Some(ev) = event_rx.recv().await {
if route_rpc.try_route(&ev.value).await {
continue; // consumed as a reply
}
total += 1;
match ev.kind.as_str() {
"server.hello" | "mob.login" | "mob.logout" | "player.death" | "vendor.sale"
@@ -49,7 +61,6 @@ async fn main() -> anyhow::Result<()> {
_ => tracing::debug!(kind = %ev.kind, n = total, "{}", ev.value),
}
// Fan out to WebSocket clients. Err just means nobody is subscribed right now.
let _ = feed_tx.send(ev.value.to_string());
}
});