//! uo-link sidecar. //! //! Terminates the loopback link to the ServUO shard and exposes a website-facing HTTP surface: the //! shard link (bidirectional), a WebSocket live feed, and REST queries backed by SQLite. //! //! # Layout //! //! `main` does argument handling and nothing else; the sidecar proper lives in [`app`] and is the //! same code on every platform. Only *how the process is started and stopped* is //! platform-specific: //! //! ```text //! systemd ──▶ main ──▶ unix::run ─────────────────────────┐ //! ├──▶ app::run //! SCM ─────▶ main ──▶ windows::run ──▶ ServiceMain ───────┘ //! └─▶ console fallback ──┘ //! ``` //! //! The Windows half is not optional politeness: the SCM refuses to supervise a program that does //! not speak its startup handshake (see [`windows`]). The platform modules are gated with `#[cfg]` //! and their dependencies are declared per target, so none of it reaches a Linux build. mod app; mod cli; mod config; mod rpc; mod shard; mod store; #[cfg(unix)] mod unix; mod web; #[cfg(windows)] mod windows; use tracing_subscriber::EnvFilter; /// Wire-protocol version between the website and the sidecar. Bump this whenever an event or /// endpoint's shape changes so a mismatched client is detected immediately (409 / health) instead /// of failing in confusing ways. /// /// 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. /// /// 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. /// /// v4 (Protocol 4): adds `guild.roster` and `guild.leave`, giving the guild board a real member list /// instead of the member *count* that was all v2 could express. Additive in the same way again — the /// kinds are new, `GET /guilds` grows a `roster` key, and nothing existing changed shape. This is the /// first bump that also needed a **store migration** (`guilds.members`), because it is the first to /// add a column to a table that already exists rather than a whole new table; see `store::migrate`. /// /// v5 (Protocol 5): three enrichments that are additive in the same way again, bumped together /// rather than one at a time because a protocol bump is not cheap here — it costs a sidecar /// release, a republished bundle and an operator update on every shard, so a field left out costs /// a whole second round of that rather than a follow-up commit. They are: /// /// * `house.decay` gains `ownerName` and a decay SCHEDULE — `nextStage`, `decayPeriodSec`, /// `dynamicDecay`, and `estimatedCollapse` only where it is exactly knowable (at IDOC under /// dynamic decay; at any stage under static decay, which has no randomness to wait out). /// * `vendor.listing` gains `ownerAcct` — without which the frame names an owner nobody can /// resolve to a person — and a `fees` object carrying the charge, the funds, the pay interval /// and the resolved `dismissalAt`. /// * `account.login.result` is a NEW kind: the verdict of a login, which the pre-existing /// `account.login.attempt` structurally cannot carry (its EventSink fires before the auth /// decision is made). /// /// **No store migration this time**, unlike v4. Every frame is persisted whole and the board tables /// index only the columns they already had, so the new fields ride inside the stored JSON and the /// new kind lands in `events` like any other. That is the dumb-forwarder property doing its job: /// the sidecar defines no schema for a frame's contents and so needs no change when they grow. /// /// v6 (Protocol 6): the first bump that is about a GUARANTEE rather than about data, and the first /// the sidecar mostly gets for free. Two things: /// /// * **`idempotencyKey` on inbound commands.** A command that carries one is executed by the shard /// at most once; a repeat is answered with the original reply rather than re-run. That is what /// makes a world-writing verb retryable at all — until now a lost acknowledgement was /// indistinguishable from a command that never applied, so the website had to declare every /// write un-retryable and accept losing one rather than risk doubling it. The sidecar's part is /// to CARRY the key (it rides in the command body, which every write endpoint already passes /// through verbatim) and to understand the one new answer the shard can now give: `bridge.busy`, /// meaning a command under that key is still in flight. See `web::respond`. /// * **`champ.boss.killed` is a new kind**: a champion's defeat, with the damage table only the /// shard ever sees. It was previously inferable from `champ.update` going `bossUp` true then /// false alongside a nearby `mob.killed`, which is fragile and says nothing about who did the /// work. It lands in `events` and on the feed like any other kind, with no code here at all — /// the dumb-forwarder property again. /// /// **No store migration.** Nothing gains a column; the new kind is persisted whole like every other. pub const PROTOCOL_VERSION: u32 = 6; // Not `#[tokio::main]`: on Windows the SCM dispatcher takes over this thread and starts the runtime // itself, on its own thread, once the service actually begins. The runtime is built by whichever // platform module ends up running. fn main() -> anyhow::Result<()> { let args = match cli::parse(std::env::args().skip(1)) { Ok(args) => args, Err(msg) => { eprintln!("uo-link-sidecar: {msg}\n\n{}", cli::USAGE); std::process::exit(2); } }; match args.mode { cli::Mode::Help => { print!("{}", cli::USAGE); return Ok(()); } cli::Mode::Version => { println!( "uo-link-sidecar {} (protocol {})", env!("CARGO_PKG_VERSION"), PROTOCOL_VERSION ); return Ok(()); } // Tracing stays uninitialized here on purpose: the subscriber writes to stdout, and stdout // is the document. Config::load's messages are dropped rather than interleaved into JSON // an installer is about to parse — everything they would have said is in the document. cli::Mode::PrintConfig => { let loaded = config::Config::load(args.config.as_deref())?; println!("{:#}", config::describe(&loaded)); return Ok(()); } cli::Mode::Run => {} } #[cfg(windows)] return windows::run(args.config.as_deref()); #[cfg(unix)] return unix::run(args.config.as_deref()); } /// Logging for a foreground run: human-readable, on stdout. pub fn init_console_tracing() { tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), ) .init(); }