//! uo-link sidecar. //! //! 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 store; mod web; use tokio::sync::{broadcast, mpsc}; use tracing::info; use tracing_subscriber::EnvFilter; const SHARD_ADDR: &str = "127.0.0.1:7788"; const WEB_ADDR: &str = "127.0.0.1:8080"; const DB_PATH: &str = "uo-link.db"; #[tokio::main] async fn main() -> anyhow::Result<()> { init_tracing(); info!("uo-link sidecar starting"); // Shard link: events in, commands out. let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); let handle = shard::serve(SHARD_ADDR, event_tx).await?; // Live feed: every shard event fans out to all connected website WebSocket clients. let (bcast_tx, _) = broadcast::channel::(1024); // Request/reply correlation for REST queries. let rpc = rpc::Rpc::new(); // Durable store: event history, economy series, cached profiles, link map. let store = store::Store::open(DB_PATH).await?; // Website-facing HTTP server. let web_state = web::AppState { events: bcast_tx.clone(), shard: handle.clone(), rpc: rpc.clone(), store: store.clone(), }; tokio::spawn(async move { if let Err(e) = web::serve(WEB_ADDR, web_state).await { tracing::error!(error = %e, "web server exited"); } }); // 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, persist it, broadcast it. let feed_tx = bcast_tx.clone(); let route_rpc = rpc.clone(); let event_store = store.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" | "house.decay" | "link.request" => { info!(kind = %ev.kind, n = total, "{}", ev.value); } _ => tracing::debug!(kind = %ev.kind, n = total, "{}", ev.value), } // Persist, then broadcast. `pong` and `ws.hello` are ephemeral chatter, not history. if ev.kind != "pong" { let t = ev.value.get("t").and_then(|v| v.as_i64()).unwrap_or_else(now_ms); let text = ev.value.to_string(); if let Err(e) = event_store.insert_event(t, &ev.kind, &text).await { tracing::warn!(error = %e, "failed to persist event"); } } let _ = feed_tx.send(ev.value.to_string()); } }); // Heartbeat to the shard, exercising the command path. let ping_handle = handle.clone(); tokio::spawn(async move { loop { tokio::time::sleep(std::time::Duration::from_secs(15)).await; if ping_handle.is_connected().await { let _ = ping_handle .send(r#"{"kind":"ping","id":"sidecar-heartbeat"}"#.to_string()) .await; } } }); tokio::signal::ctrl_c().await?; info!("shutting down"); Ok(()) } fn now_ms() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as i64) .unwrap_or(0) } fn init_tracing() { tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), ) .init(); }