From be03b910619ba999a8a81f8640b31e3d5902c6b7 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 23:54:37 -0500 Subject: [PATCH 01/21] feat(game)!: NetSim on celld zone objects The development network simulation (latency, jitter, loss as a retransmit delay) moves to a portable `distributed_game::netsim` module (`NetSim`, an order-preserving `DelayLine` over millisecond timestamps, `NetSimJson` for dev routes); `net` re-exports `NetSim`. Zones hosted as celld objects apply it per socket and direction: outbound frames (and closes) and inbound inputs and pings go through delay lines drained by `setTimeout` tasks. With ZONE_DEV_SETTINGS=1 a zone reads the setting from the worker's dev settings object (`celld_zone::ZoneDevSettings`, `zone-dev/netsim`) on wake and every second while it ticks; `/rooms/{zone}/info` reports it. Refs [[tasks/gr-p44-mobpit-world]] --- distributed_game/README.md | 14 +- distributed_game/src/celld_zone/cells.rs | 45 ++++ distributed_game/src/celld_zone/mod.rs | 12 +- distributed_game/src/celld_zone/object.rs | 274 ++++++++++++++++++-- distributed_game/src/celld_zone/plan.rs | 9 + distributed_game/src/lib.rs | 1 + distributed_game/src/net/mod.rs | 110 +------- distributed_game/src/netsim.rs | 290 ++++++++++++++++++++++ 8 files changed, 619 insertions(+), 136 deletions(-) create mode 100644 distributed_game/src/netsim.rs diff --git a/distributed_game/README.md b/distributed_game/README.md index b76fba40..39498300 100644 --- a/distributed_game/README.md +++ b/distributed_game/README.md @@ -95,13 +95,13 @@ The words used throughout: ## Try it -Eight runnable demos live in [`examples/game`](../examples/game/README.md), -each built to show one part of the engine: +The runnable demos live in [`examples/game`](../examples/game/README.md). +They are being folded into one game, Mob Pit (the others are deleted as +their mechanic moves in): | Demo | Run | Shows | |---|---|---| -| Arena | `examples/game/run.sh arena` | ticks, deltas, prediction on/off, interest radius vs. global entities, simulated latency | -| Mob Pit | `run.sh mobpit` (`--fleet` for several celld nodes) | rooms as celld objects joined by doorways, sleeping when empty and restored on return; combat whose rewards are saved exactly once without stalling | +| Mob Pit | `examples/game/run.sh mobpit` (`--fleet` for several celld nodes) | rooms as celld objects joined by doorways, sleeping when empty and restored on return; combat whose rewards are saved exactly once without stalling; a network panel (simulated latency / jitter / loss on every room, prediction on/off with a server ghost, the interest ring) | | Leaderboard | `run.sh leaderboard` | a live ranking fed from saved kills, rebuilt from history | | Shooting Gallery | `run.sh gallery` | lag compensation on/off under latency | | The Freeze | `run.sh freeze` | pausing every zone on two servers, updating content, resuming; safe-room reloads | @@ -1175,6 +1175,12 @@ principals commit concurrently; settlements arrive in completion order. `DG_NET_LATENCY_MS`, `DG_NET_JITTER_MS`, `DG_NET_LOSS`, `DG_NET_RETRANSMIT_MS`. `GameServer::set_net_sim(Some(sim) | None)` changes it at runtime for every connection (order is preserved). + `distributed_game::netsim` is portable: zones hosted as celld objects + (`celld-zone`) apply the same simulation to each socket when the worker + sets `ZONE_DEV_SETTINGS=1`; the setting lives in the worker's dev + settings object (`celld_zone::ZoneDevSettings`, `PUT /zone-dev/netsim` + `{latencyMs, jitterMs, loss}` with the internal secret) and every zone + object reads it within a second. ## World structure diff --git a/distributed_game/src/celld_zone/cells.rs b/distributed_game/src/celld_zone/cells.rs index b03f7bce..a72b21e0 100644 --- a/distributed_game/src/celld_zone/cells.rs +++ b/distributed_game/src/celld_zone/cells.rs @@ -187,3 +187,48 @@ async fn read_activity(cell: &AggregateCell) -> std::result::Resul let activity = cell.load().await.map_err(|error| error.to_string())?; Ok(json!({ "activity": activity.map(|a| a.row()) })) } + +/// Dev-only settings shared by every zone object of a worker (kind +/// [`ZONE_DEV_KIND`](super::ZONE_DEV_KIND), binding `ZONE_DEV`), e.g. the +/// network simulation under shard [`NETSIM_SHARD`](super::NETSIM_SHARD). +/// `GET /zone-dev/{shard}` returns the stored JSON (`{}` when unset), `PUT` +/// (or `POST`) replaces it. Every request needs the internal secret. Zone +/// objects read it only when `ZONE_DEV_SETTINGS=1`. +pub struct ZoneDevSettings { + state: State, + env: Env, +} + +impl ZoneDevSettings { + /// The settings object of `state`. + pub fn new(state: State, env: Env) -> Self { + Self { state, env } + } + + /// Durable Object `fetch`. + pub async fn fetch(&self, mut req: Request) -> Result { + if let Err(error) = authenticate(&req, &self.env) { + return error_response(error); + } + const KEY: &str = "settings"; + match req.method() { + Method::Get => { + let stored: Option = self.state.storage().get(KEY).await.ok().flatten(); + json_status(stored.unwrap_or_else(|| json!({})), 200) + } + Method::Put | Method::Post => { + let body: Value = match req.json().await { + Ok(body @ Value::Object(_)) => body, + _ => { + return error_response(CellDispatchError::BadRequest( + "a JSON object is required".into(), + )) + } + }; + self.state.storage().put(KEY, &body).await?; + json_status(body, 200) + } + _ => json_status(json!({ "error": "not found" }), 404), + } + } +} diff --git a/distributed_game/src/celld_zone/mod.rs b/distributed_game/src/celld_zone/mod.rs index e7b50c7a..03dc3369 100644 --- a/distributed_game/src/celld_zone/mod.rs +++ b/distributed_game/src/celld_zone/mod.rs @@ -55,6 +55,13 @@ //! loses at most the snapshot interval of zone state (facts are kept by //! the outbox) and takes the node lease TTL (~10 s) to recover. //! +//! Dev only: with `ZONE_DEV_SETTINGS=1` every zone object applies the +//! network simulation ([`NetSim`](crate::netsim::NetSim): latency, jitter, +//! loss per socket and direction) stored in the worker's +//! [`ZoneDevSettings`] object (`zone-dev/netsim`), read on wake and every +//! second while it ticks; a dev route writes it (one setting for every +//! zone, like the native server's `set_net_sim`). +//! //! Debug only: celld does not tell application code which node runs an //! object. With `CELLD_ZONE_NODES` set (operator listeners) the object asks //! each node whether it holds it; `CELLD_ZONE_NODE` names a fixed node. @@ -68,13 +75,14 @@ mod cells; mod object; pub use plan::{ - cell_binding, ObjectOptions, RoomState, StoredFact, TickSchedule, CLOSE_RESTARTED, ZONE_HEADER, + cell_binding, ObjectOptions, RoomState, StoredFact, TickSchedule, CLOSE_RESTARTED, + DEV_SETTINGS_VAR, NETSIM_SHARD, ZONE_DEV_KIND, ZONE_HEADER, }; #[cfg(target_arch = "wasm32")] pub use cells::{ authenticate, cell_alarm, error_response, fact_sessions, json_status, serve_cell, Sessions, - ZoneActivityCell, + ZoneActivityCell, ZoneDevSettings, }; #[cfg(target_arch = "wasm32")] pub use object::{ diff --git a/distributed_game/src/celld_zone/object.rs b/distributed_game/src/celld_zone/object.rs index 9e347852..3c86293f 100644 --- a/distributed_game/src/celld_zone/object.rs +++ b/distributed_game/src/celld_zone/object.rs @@ -4,7 +4,7 @@ use std::cell::RefCell; use std::collections::HashMap; use std::future::Future; use std::pin::Pin; -use std::rc::Rc; +use std::rc::{Rc, Weak}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -24,7 +24,7 @@ use worker::{ use super::plan::{ cell_binding, classify_wait_path, zone_from_path, ObjectOptions, RoomState, StoredFact, - TickSchedule, Window, CLOSE_RESTARTED, OUTBOX_PREFIX, ZONE_HEADER, + TickSchedule, Window, CLOSE_RESTARTED, NETSIM_SHARD, OUTBOX_PREFIX, ZONE_DEV_KIND, ZONE_HEADER, }; use crate::command_sink::{CommandFactSink, FactCall, FactHost, FactRoute}; use crate::facts::{ @@ -32,6 +32,7 @@ use crate::facts::{ }; use crate::handoff::{HandoffRequest, Transfer}; use crate::inbox::ZoneInbound; +use crate::netsim::{DelayLine, NetSim, NetSimJson}; use crate::protocol::{error, ClientMessage, Role, ServerMessage, PROTOCOL_VERSION}; use crate::stamp::FactStamp; use crate::ticket::{NonceCache, TicketKey, VerifiedTicket}; @@ -56,6 +57,24 @@ const TRANSFER_TTL_SECS: u64 = 30; const FACT_CAPACITY: usize = 512; /// Retry stored facts this often while any is unsettled (ms). const OUTBOX_RETRY_MS: f64 = 10_000.0; +/// Read the dev settings this often while ticking (ms). +const DEV_SETTINGS_EVERY_MS: f64 = 1_000.0; + +/// Server → client, through the socket's simulated network. +enum OutItem { + Frame(Vec), + Close, +} + +/// Client → server, through the socket's simulated network. +enum InItem { + Input { + seq: u32, + client_tick: u32, + payload: Vec, + }, + Ping(f64), +} /// A game zone the framework can host as a celld Durable Object: one object /// per zone id (celld places it, forwards to it, hibernates and moves it). @@ -381,9 +400,136 @@ struct Inner { stats: TickStats, last_persist_ms: f64, spooled: u64, + /// Dev network simulation in effect (from the dev settings object). + net_sim: Option, + /// Per socket: server → client and client → server delay lines. + out_lines: HashMap>, + in_lines: HashMap>, + /// When the pending drain task wakes up (one per earliest deadline). + drain_at: Option, + /// Spawns a drain task waking at the given time (set by the object). + drainer: Option>, + last_dev_read_ms: f64, } impl Inner { + /// Send `item` to `client` through its simulated network. + fn send_out(&mut self, client: u32, item: OutItem) { + let sim = self.net_sim; + let line = self + .out_lines + .entry(client) + .or_insert_with(|| DelayLine::new(u64::from(client) ^ 0x5eed_0u64)); + match line.push(sim, now_ms(), item) { + Some(item) => self.deliver(client, item), + None => { + if let Some(due) = line.next_due() { + self.want_drain(due); + } + } + } + } + + fn deliver(&mut self, client: u32, item: OutItem) { + match item { + OutItem::Frame(frame) => { + if let Some(ws) = self.sockets.get(&client) { + let _ = ws.send_with_bytes(frame); + } + } + OutItem::Close => { + self.out_lines.remove(&client); + self.in_lines.remove(&client); + if let Some(ws) = self.sockets.remove(&client) { + let _ = ws.close(Some(1000), Some("closed by zone")); + } + } + } + } + + /// Take `item` from `client` through its simulated network. + fn receive(&mut self, client: u32, item: InItem) { + let sim = self.net_sim; + let line = self + .in_lines + .entry(client) + .or_insert_with(|| DelayLine::new(u64::from(client) ^ 0xfeed_0u64)); + match line.push(sim, now_ms(), item) { + Some(item) => self.apply(client, item), + None => { + if let Some(due) = line.next_due() { + self.want_drain(due); + } + } + } + } + + fn apply(&mut self, client: u32, item: InItem) { + match item { + InItem::Input { + seq, + client_tick, + payload, + } => { + if let Some(host) = self.host.as_mut() { + host.push_input(ClientId(client), seq, client_tick, &payload); + } + } + InItem::Ping(client_time) => { + let server_time = self.host.as_ref().map_or(0.0, |h| h.server_time_ms()); + if let Ok(pong) = (ServerMessage::Pong { + client_time, + server_time, + }) + .encode(&()) + { + self.send_out(client, OutItem::Frame(pong)); + } + } + } + } + + /// Deliver and apply everything due at `now`. + fn drain_due(&mut self, now: f64) { + let clients: Vec = self.in_lines.keys().copied().collect(); + for client in clients { + while let Some(item) = self.in_lines.get_mut(&client).and_then(|l| l.pop_due(now)) { + self.apply(client, item); + } + } + let clients: Vec = self.out_lines.keys().copied().collect(); + for client in clients { + while let Some(item) = self.out_lines.get_mut(&client).and_then(|l| l.pop_due(now)) { + self.deliver(client, item); + } + } + } + + fn next_due(&self) -> Option { + self.in_lines + .values() + .filter_map(DelayLine::next_due) + .chain(self.out_lines.values().filter_map(DelayLine::next_due)) + .min_by(f64::total_cmp) + } + + /// Make sure a drain task wakes up by `due`. + fn want_drain(&mut self, due: f64) { + if self.drain_at.is_some_and(|at| at <= due) { + return; + } + self.drain_at = Some(due); + if let Some(drainer) = self.drainer.clone() { + drainer(due); + } + } + + /// Forget a socket's delay lines. + fn forget(&mut self, client: u32) { + self.out_lines.remove(&client); + self.in_lines.remove(&client); + } + fn occupied(&self) -> bool { self.host .as_ref() @@ -394,18 +540,13 @@ impl Inner { let Some(host) = self.host.as_mut() else { return; }; - for out in host.drain_outbound() { + let outbound = host.drain_outbound(); + for out in outbound { match out { Outbound::Frame { client, frame } => { - if let Some(ws) = self.sockets.get(&client.0) { - let _ = ws.send_with_bytes(frame); - } - } - Outbound::Close { client } => { - if let Some(ws) = self.sockets.remove(&client.0) { - let _ = ws.close(Some(1000), Some("closed by zone")); - } + self.send_out(client.0, OutItem::Frame(frame)); } + Outbound::Close { client } => self.send_out(client.0, OutItem::Close), } } } @@ -534,11 +675,22 @@ impl ZoneObject { stats: TickStats::default(), last_persist_ms: 0.0, spooled: 0, + net_sim: None, + out_lines: HashMap::new(), + in_lines: HashMap::new(), + drain_at: None, + drainer: None, + last_dev_read_ms: 0.0, }; + let inner = Rc::new(RefCell::new(inner)); + let weak = Rc::downgrade(&inner); + inner.borrow_mut().drainer = Some(Rc::new(move |due: f64| { + wasm_bindgen_futures::spawn_local(drain_task(weak.clone(), due)); + })); Self { state: Rc::new(state), env, - inner: Rc::new(RefCell::new(inner)), + inner, } } @@ -616,17 +768,26 @@ impl ZoneObject { }, ) => { let mut inner = self.inner.borrow_mut(); - match inner.host.as_mut() { - Some(host) => { - host.push_input(ClientId(client), seq, client_tick, &payload); - } - None => { - drop(inner); - let _ = ws.close(Some(CLOSE_RESTARTED), Some("zone asleep; rejoin")); - } + if inner.host.is_some() { + inner.receive( + client, + InItem::Input { + seq, + client_tick, + payload, + }, + ); + } else { + drop(inner); + let _ = ws.close(Some(CLOSE_RESTARTED), Some("zone asleep; rejoin")); } } - (_, ClientMessage::Ping { client_time }) => { + (Some(client), ClientMessage::Ping { client_time }) => { + self.inner + .borrow_mut() + .receive(client, InItem::Ping(client_time)); + } + (None, ClientMessage::Ping { client_time }) => { let server_time = self .inner .borrow() @@ -646,6 +807,7 @@ impl ZoneObject { { let mut inner = self.inner.borrow_mut(); inner.sockets.remove(&client); + inner.forget(client); if let Some(host) = inner.host.as_mut() { host.disconnect(ClientId(client), true); } @@ -909,9 +1071,47 @@ impl ZoneObject { } self.store_status().await; self.find_node(); + self.read_dev_settings().await; Ok(()) } + /// Dev only (`ZONE_DEV_SETTINGS=1`): read the network simulation from + /// the worker's dev settings object. + async fn read_dev_settings(&self) { + let enabled = { + let mut inner = self.inner.borrow_mut(); + inner.last_dev_read_ms = now_ms(); + inner.options.as_ref().is_some_and(|o| o.dev_settings) + }; + if !enabled { + return; + } + match self.env.read_cell(ZONE_DEV_KIND, NETSIM_SHARD).await { + Ok(value) => { + let sim = serde_json::from_value::(value) + .unwrap_or_default() + .to_sim(); + self.inner.borrow_mut().net_sim = sim; + } + Err(e) => worker::console_warn!("zone dev settings: {e}"), + } + } + + /// [`read_dev_settings`](Self::read_dev_settings) off the tick, at most + /// every [`DEV_SETTINGS_EVERY_MS`]. + fn refresh_dev_settings(&self) { + let due = { + let inner = self.inner.borrow(); + inner.options.as_ref().is_some_and(|o| o.dev_settings) + && now_ms() - inner.last_dev_read_ms >= DEV_SETTINGS_EVERY_MS + }; + if due { + self.inner.borrow_mut().last_dev_read_ms = now_ms(); + let this = self.handle(); + wasm_bindgen_futures::spawn_local(async move { this.read_dev_settings().await }); + } + } + async fn hello(&self, ws: &WebSocket, hello: crate::protocol::Hello) -> Result<()> { if hello.protocol_version != PROTOCOL_VERSION { reject(ws, error::VERSION_MISMATCH, "protocol version"); @@ -1040,6 +1240,7 @@ impl ZoneObject { let mut inner = self.inner.borrow_mut(); if let Some(a) = attached.filter(|a| a.activation == inner.activation) { inner.sockets.remove(&a.client); + inner.forget(a.client); if let Some(host) = inner.host.as_mut() { host.disconnect(ClientId(a.client), false); } @@ -1108,6 +1309,7 @@ impl ZoneObject { (stop, persist) }; self.spool().await; + self.refresh_dev_settings(); if persist { self.persist().await; if !stop { @@ -1298,11 +1500,41 @@ impl ZoneObject { "iterations": stats.iterations, "droppedTicks": stats.dropped, "lateMs": { "p50": stats.late.percentile(0.5), "p99": stats.late.percentile(0.99) }, + "netsim": NetSim::to_json(inner.net_sim), "workMs": { "p50": stats.work.percentile(0.5), "p99": stats.work.percentile(0.99) }, }) } } +/// Deliver what the delay lines hold as it falls due, then stop (a newer +/// task with an earlier deadline takes over). +async fn drain_task(inner: Weak>>, mut target: f64) { + loop { + Delay::from(Duration::from_micros( + ((target - now_ms()).max(0.0) * 1000.0) as u64, + )) + .await; + let Some(inner) = inner.upgrade() else { + return; + }; + let mut inner = inner.borrow_mut(); + inner.drain_due(now_ms()); + if inner.drain_at != Some(target) { + return; + } + match inner.next_due() { + Some(next) => { + inner.drain_at = Some(next); + target = next; + } + None => { + inner.drain_at = None; + return; + } + } + } +} + fn retry_policy() -> RetryPolicy { RetryPolicy::until_settled(Duration::from_millis(100), Duration::from_secs(5)) } diff --git a/distributed_game/src/celld_zone/plan.rs b/distributed_game/src/celld_zone/plan.rs index c9e4ab28..f2847e1d 100644 --- a/distributed_game/src/celld_zone/plan.rs +++ b/distributed_game/src/celld_zone/plan.rs @@ -36,6 +36,11 @@ pub const NODES_VAR: &str = "CELLD_ZONE_NODES"; /// Env var (debug only): a fixed node name (e.g. `dev` for `celld dev`). pub const NODE_VAR: &str = "CELLD_ZONE_NODE"; +/// Env var (dev only): `1` makes zone objects read the worker's dev +/// settings (the network simulation, [`NETSIM_SHARD`]). +pub const DEV_SETTINGS_VAR: &str = "ZONE_DEV_SETTINGS"; +pub use crate::netsim::{NETSIM_SHARD, ZONE_DEV_KIND}; + /// Request header naming the zone (set by [`super::forward_to_zone`]; the /// object's id has no name on some celld paths). pub const ZONE_HEADER: &str = "x-celld-zone"; @@ -67,6 +72,8 @@ pub struct ObjectOptions { pub nodes: Vec<(String, String)>, /// Debug fixed node name. pub node: Option, + /// Dev only: read the worker's dev settings (network simulation). + pub dev_settings: bool, } fn parse( @@ -116,6 +123,8 @@ impl ObjectOptions { .unwrap_or_else(|| "celld-zone".into()), nodes, node: get(NODE_VAR).filter(|node| !node.is_empty()), + dev_settings: get(DEV_SETTINGS_VAR) + .is_some_and(|v| matches!(v.trim(), "1" | "true" | "yes")), }) } } diff --git a/distributed_game/src/lib.rs b/distributed_game/src/lib.rs index 03fe2fdb..51766bc2 100644 --- a/distributed_game/src/lib.rs +++ b/distributed_game/src/lib.rs @@ -78,6 +78,7 @@ pub mod lifecycle; pub mod metrics; #[cfg(feature = "net")] pub mod net; +pub mod netsim; pub mod prediction; pub mod protocol; pub mod pure; diff --git a/distributed_game/src/net/mod.rs b/distributed_game/src/net/mod.rs index b94220d5..ce957f8b 100644 --- a/distributed_game/src/net/mod.rs +++ b/distributed_game/src/net/mod.rs @@ -92,82 +92,7 @@ use crate::world::{ }; use crate::zone::{ClientId, TicketClaims, Tier, Zone}; -/// Development network simulation, applied independently to each direction -/// of every connection. -/// -/// Semantics over WebSocket (a reliable, ordered stream): a message is never -/// dropped or reordered, because Deltas are encoded against the previous -/// one. `loss` models what TCP does with a lost segment instead: with -/// probability `loss` a message is delayed by an extra `retransmit` (repeated -/// with the same probability, at most 3 times), and since order is preserved -/// every later message queues behind it (head-of-line blocking). Total -/// one-way delay = `latency + U[0, jitter] + k·retransmit`. -#[derive(Debug, Clone, Copy, PartialEq, Default)] -pub struct NetSim { - /// Fixed one-way delay. - pub latency: Duration, - /// Extra uniform random delay in `[0, jitter]` (order is preserved). - pub jitter: Duration, - /// Probability in `[0, 1]` that a message needs a retransmission. - pub loss: f32, - /// Delay added per simulated retransmission (TCP minimum RTO ≈ 200 ms). - pub retransmit: Duration, -} - -impl NetSim { - /// Latency + jitter, no loss. - pub fn new(latency: Duration, jitter: Duration) -> Self { - Self { - latency, - jitter, - loss: 0.0, - retransmit: Duration::from_millis(200), - } - } - - /// Add simulated loss (see the type docs for WebSocket semantics). - pub fn with_loss(mut self, loss: f32, retransmit: Duration) -> Self { - self.loss = loss.clamp(0.0, 1.0); - self.retransmit = retransmit; - self - } - - /// Read `DG_NET_LATENCY_MS`, `DG_NET_JITTER_MS`, `DG_NET_LOSS` (fraction, - /// e.g. `0.02`) and `DG_NET_RETRANSMIT_MS` (default 200). `None` when - /// none of the first three is set or all are zero. - pub fn from_env() -> Option { - Self::from_lookup(|key| std::env::var(key).ok()) - } - - /// [`from_env`](Self::from_env) over any key lookup. - pub fn from_lookup(lookup: impl Fn(&str) -> Option) -> Option { - let ms = |key: &str| { - lookup(key) - .and_then(|v| v.trim().parse::().ok()) - .map(Duration::from_millis) - }; - let latency = ms("DG_NET_LATENCY_MS").unwrap_or_default(); - let jitter = ms("DG_NET_JITTER_MS").unwrap_or_default(); - let loss = lookup("DG_NET_LOSS") - .and_then(|v| v.trim().parse::().ok()) - .unwrap_or(0.0); - let retransmit = ms("DG_NET_RETRANSMIT_MS").unwrap_or(Duration::from_millis(200)); - (!latency.is_zero() || !jitter.is_zero() || loss > 0.0) - .then(|| Self::new(latency, jitter).with_loss(loss, retransmit)) - } - - /// One-way delay for one message. - fn delay(&self, rng: &mut ZoneRng) -> Duration { - let mut delay = self.latency + self.jitter.mul_f32(rng.next_f32()); - for _ in 0..3 { - if self.loss <= 0.0 || !rng.chance(self.loss) { - break; - } - delay += self.retransmit; - } - delay - } -} +pub use crate::netsim::NetSim; /// Server settings. #[derive(Debug, Clone)] @@ -2067,39 +1992,6 @@ async fn forward_in( mod netsim_tests { use super::*; - #[test] - fn netsim_from_env_and_loss_semantics() { - let env = |pairs: &'static [(&'static str, &'static str)]| { - move |k: &str| { - pairs - .iter() - .find(|(key, _)| *key == k) - .map(|(_, v)| v.to_string()) - } - }; - assert_eq!(NetSim::from_lookup(env(&[])), None); - let sim = NetSim::from_lookup(env(&[ - ("DG_NET_LATENCY_MS", "40"), - ("DG_NET_JITTER_MS", "10"), - ("DG_NET_LOSS", "0.5"), - ])) - .unwrap(); - assert_eq!(sim.latency, Duration::from_millis(40)); - assert_eq!(sim.jitter, Duration::from_millis(10)); - assert_eq!(sim.retransmit, Duration::from_millis(200)); - let mut rng = ZoneRng::from_seed(7); - let delays: Vec = (0..2000).map(|_| sim.delay(&mut rng)).collect(); - let base_max = Duration::from_millis(50); - let retransmitted = delays.iter().filter(|d| **d > base_max).count(); - // P(at least one retransmit) = 0.5. - assert!((850..1150).contains(&retransmitted), "{retransmitted}"); - assert!(delays.iter().all(|d| *d >= sim.latency)); - assert!(delays.iter().all(|d| *d <= base_max + sim.retransmit * 3)); - // Without loss the delay is bounded by latency + jitter. - let clean = NetSim::new(Duration::from_millis(40), Duration::from_millis(10)); - assert!((0..500).all(|_| clean.delay(&mut rng) <= base_max)); - } - #[test] fn live_net_sim_changes_preserve_order() { let (tx, rx) = watch::channel(None); diff --git a/distributed_game/src/netsim.rs b/distributed_game/src/netsim.rs new file mode 100644 index 00000000..44b7ad36 --- /dev/null +++ b/distributed_game/src/netsim.rs @@ -0,0 +1,290 @@ +//! Development network simulation (latency, jitter, loss), portable: the +//! native WebSocket server ([`crate::net`]) and the celld zone host +//! (`celld_zone`, wasm32) both apply it per connection and direction. +//! +//! [`NetSim`] is the setting; [`DelayLine`] is the order-preserving queue a +//! host puts one direction of one connection through, over plain +//! millisecond timestamps (so it runs on Tokio's clock and on `Date.now()`). + +use std::collections::VecDeque; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::rng::ZoneRng; + +/// Cell kind of a celld worker's dev settings object (binding `ZONE_DEV`, +/// `celld_zone::ZoneDevSettings`), where celld zone objects read the +/// network simulation. +pub const ZONE_DEV_KIND: &str = "zone-dev"; +/// Shard of the dev settings holding the network simulation ([`NetSimJson`]). +pub const NETSIM_SHARD: &str = "netsim"; + +/// Development network simulation, applied independently to each direction +/// of every connection. +/// +/// Semantics over WebSocket (a reliable, ordered stream): a message is never +/// dropped or reordered, because Deltas are encoded against the previous +/// one. `loss` models what TCP does with a lost segment instead: with +/// probability `loss` a message is delayed by an extra `retransmit` (repeated +/// with the same probability, at most 3 times), and since order is preserved +/// every later message queues behind it (head-of-line blocking). Total +/// one-way delay = `latency + U[0, jitter] + k·retransmit`. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct NetSim { + /// Fixed one-way delay. + pub latency: Duration, + /// Extra uniform random delay in `[0, jitter]` (order is preserved). + pub jitter: Duration, + /// Probability in `[0, 1]` that a message needs a retransmission. + pub loss: f32, + /// Delay added per simulated retransmission (TCP minimum RTO ≈ 200 ms). + pub retransmit: Duration, +} + +impl NetSim { + /// Latency + jitter, no loss. + pub fn new(latency: Duration, jitter: Duration) -> Self { + Self { + latency, + jitter, + loss: 0.0, + retransmit: Duration::from_millis(200), + } + } + + /// Add simulated loss (see the type docs for WebSocket semantics). + pub fn with_loss(mut self, loss: f32, retransmit: Duration) -> Self { + self.loss = loss.clamp(0.0, 1.0); + self.retransmit = retransmit; + self + } + + /// Read `DG_NET_LATENCY_MS`, `DG_NET_JITTER_MS`, `DG_NET_LOSS` (fraction, + /// e.g. `0.02`) and `DG_NET_RETRANSMIT_MS` (default 200). `None` when + /// none of the first three is set or all are zero. + pub fn from_env() -> Option { + Self::from_lookup(|key| std::env::var(key).ok()) + } + + /// [`from_env`](Self::from_env) over any key lookup. + pub fn from_lookup(lookup: impl Fn(&str) -> Option) -> Option { + let ms = |key: &str| { + lookup(key) + .and_then(|v| v.trim().parse::().ok()) + .map(Duration::from_millis) + }; + let latency = ms("DG_NET_LATENCY_MS").unwrap_or_default(); + let jitter = ms("DG_NET_JITTER_MS").unwrap_or_default(); + let loss = lookup("DG_NET_LOSS") + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(0.0); + let retransmit = ms("DG_NET_RETRANSMIT_MS").unwrap_or(Duration::from_millis(200)); + (!latency.is_zero() || !jitter.is_zero() || loss > 0.0) + .then(|| Self::new(latency, jitter).with_loss(loss, retransmit)) + } + + /// One-way delay for one message. + pub fn delay(&self, rng: &mut ZoneRng) -> Duration { + let mut delay = self.latency + self.jitter.mul_f32(rng.next_f32()); + for _ in 0..3 { + if self.loss <= 0.0 || !rng.chance(self.loss) { + break; + } + delay += self.retransmit; + } + delay + } + + /// The JSON shape of dev controls (`{latencyMs, jitterMs, loss}`); `None` + /// reads as all zero. + pub fn to_json(sim: Option) -> NetSimJson { + let sim = sim.unwrap_or_default(); + NetSimJson { + latency_ms: sim.latency.as_millis() as u64, + jitter_ms: sim.jitter.as_millis() as u64, + loss: sim.loss, + } + } +} + +/// A [`NetSim`] as dev routes exchange it: milliseconds, loss as a +/// fraction (retransmit is the default 200 ms). +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetSimJson { + /// One-way latency (ms). + #[serde(default)] + pub latency_ms: u64, + /// Jitter (ms). + #[serde(default)] + pub jitter_ms: u64, + /// Loss (fraction, `0..=0.5`). + #[serde(default)] + pub loss: f32, +} + +impl NetSimJson { + /// The simulation (bounded for dev use: ≤ 2 s latency and jitter, ≤ 50 % + /// loss); `None` when everything is zero. + pub fn to_sim(self) -> Option { + let latency = Duration::from_millis(self.latency_ms.min(2_000)); + let jitter = Duration::from_millis(self.jitter_ms.min(2_000)); + let loss = if self.loss.is_finite() { + self.loss.clamp(0.0, 0.5) + } else { + 0.0 + }; + (!latency.is_zero() || !jitter.is_zero() || loss > 0.0) + .then(|| NetSim::new(latency, jitter).with_loss(loss, Duration::from_millis(200))) + } +} + +/// Order-preserving delay line for one direction of one connection, over +/// millisecond timestamps. +#[derive(Debug)] +pub struct DelayLine { + rng: ZoneRng, + queue: VecDeque<(f64, T)>, + last: f64, +} + +impl DelayLine { + /// An empty line (`seed` drives jitter and loss). + pub fn new(seed: u64) -> Self { + Self { + rng: ZoneRng::from_seed(seed), + queue: VecDeque::new(), + last: f64::MIN, + } + } + + /// Queue `item` sent at `now_ms` under `sim`. Returns it back when it is + /// due immediately (no simulation and nothing still queued: order is + /// preserved when the simulation is switched off mid-flight). + pub fn push(&mut self, sim: Option, now_ms: f64, item: T) -> Option { + let delay = match sim { + Some(sim) => sim.delay(&mut self.rng).as_secs_f64() * 1000.0, + None if self.queue.is_empty() => return Some(item), + None => 0.0, + }; + let at = (now_ms + delay).max(self.last); + self.last = at; + self.queue.push_back((at, item)); + None + } + + /// When the next item is due (ms), if any is queued. + pub fn next_due(&self) -> Option { + self.queue.front().map(|(at, _)| *at) + } + + /// The next item if it is due at `now_ms`. + pub fn pop_due(&mut self, now_ms: f64) -> Option { + if self.queue.front()?.0 <= now_ms { + self.queue.pop_front().map(|(_, item)| item) + } else { + None + } + } + + /// Items queued. + pub fn len(&self) -> usize { + self.queue.len() + } + + /// Nothing queued. + pub fn is_empty(&self) -> bool { + self.queue.is_empty() + } + + /// Drop everything queued. + pub fn clear(&mut self) { + self.queue.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_env_and_loss_semantics() { + let env = |pairs: &'static [(&'static str, &'static str)]| { + move |k: &str| { + pairs + .iter() + .find(|(key, _)| *key == k) + .map(|(_, v)| v.to_string()) + } + }; + assert_eq!(NetSim::from_lookup(env(&[])), None); + let sim = NetSim::from_lookup(env(&[ + ("DG_NET_LATENCY_MS", "40"), + ("DG_NET_JITTER_MS", "10"), + ("DG_NET_LOSS", "0.5"), + ])) + .unwrap(); + assert_eq!(sim.latency, Duration::from_millis(40)); + assert_eq!(sim.jitter, Duration::from_millis(10)); + assert_eq!(sim.retransmit, Duration::from_millis(200)); + let mut rng = ZoneRng::from_seed(7); + let delays: Vec = (0..2000).map(|_| sim.delay(&mut rng)).collect(); + let base_max = Duration::from_millis(50); + let retransmitted = delays.iter().filter(|d| **d > base_max).count(); + // P(at least one retransmit) = 0.5. + assert!((850..1150).contains(&retransmitted), "{retransmitted}"); + assert!(delays.iter().all(|d| *d >= sim.latency)); + assert!(delays.iter().all(|d| *d <= base_max + sim.retransmit * 3)); + // Without loss the delay is bounded by latency + jitter. + let clean = NetSim::new(Duration::from_millis(40), Duration::from_millis(10)); + assert!((0..500).all(|_| clean.delay(&mut rng) <= base_max)); + } + + #[test] + fn switching_the_simulation_preserves_order() { + let mut line = DelayLine::new(1); + let on = Some(NetSim::new(Duration::from_millis(200), Duration::ZERO)); + assert_eq!(line.push(None, 0.0, 1), Some(1), "no simulation: immediate"); + assert_eq!( + line.push(on, 0.0, 2), + None, + "simulation switched on: delayed" + ); + assert_eq!(line.next_due(), Some(200.0)); + assert_eq!( + line.push(None, 10.0, 3), + None, + "switched off: queues behind 2" + ); + assert_eq!(line.pop_due(150.0), None); + assert_eq!(line.pop_due(200.0), Some(2)); + assert_eq!(line.pop_due(200.0), Some(3)); + assert_eq!( + line.push(None, 300.0, 4), + Some(4), + "drained: immediate again" + ); + } + + #[test] + fn json_round_trip_is_bounded() { + let json = NetSimJson { + latency_ms: 80, + jitter_ms: 20, + loss: 0.05, + }; + let sim = json.to_sim().expect("on"); + assert_eq!(NetSim::to_json(Some(sim)), json); + assert_eq!(NetSimJson::default().to_sim(), None); + let wild = NetSimJson { + latency_ms: 99_999, + jitter_ms: 0, + loss: f32::NAN, + } + .to_sim() + .expect("on"); + assert_eq!(wild.latency, Duration::from_millis(2_000)); + assert_eq!(wild.loss, 0.0); + } +} From 6e3bb8d1c3e7e0c2d3b43fb34e42731182499d30 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 23:54:39 -0500 Subject: [PATCH 02/21] feat(game)!: Mob Pit network panel, delete the Arena demo Mob Pit gets the Arena's network panel on its celld rooms: the HUD's latency / jitter / loss sliders write the cells worker's dev settings (`/dev/netsim` on the services, a dev-only SvelteKit route) and every room delays its sockets; P toggles prediction, G shows the server ghost (the room's authoritative copy), the readout counts pending inputs, acks and corrections, and the dashed ring is the interest radius (24 m, now in Hello): mobs and players outside are not replicated. The Arena demo is deleted (server module, smoke test, page, generated client, headless test, run.sh entry, README rows). The Freeze keeps the Arena capsule view and bot encoder; PIT_WORLD carries the floor constants Mob Pit drew from ARENA_FLOOR. Tests: new mobpit-network-headless (exact WASM prediction against the room, NetSim raising RTT through celld, interest culling); SvelteKit Playwright checks the latency slider and the ghost gap. Refs [[tasks/gr-p44-mobpit-world]] --- examples/game/README.md | 78 +-- examples/game/cells/src/lib.rs | 26 +- examples/game/cells/wrangler.jsonc | 11 +- examples/game/client/arena/index.html | 13 - examples/game/client/index.html | 1 - examples/game/client/src/arena/client.ts | 47 -- examples/game/client/src/arena/main.ts | 241 --------- examples/game/client/src/freeze/main.ts | 2 +- .../game/client/src/generated/arena/client.ts | 68 --- .../client/src/generated/arena/constants.ts | 22 - .../game/client/src/generated/arena/events.ts | 79 --- .../game/client/src/generated/arena/index.ts | 9 - .../game/client/src/generated/arena/input.ts | 51 -- .../client/src/generated/arena/predict.ts | 51 -- .../game/client/src/generated/arena/schema.ts | 136 ----- .../client/src/generated/arena/surface.json | 397 --------------- .../client/src/generated/freeze/constants.ts | 20 +- .../client/src/generated/freeze/events.ts | 15 + .../client/src/generated/freeze/surface.json | 115 +++++ .../client/src/generated/mobpit/constants.ts | 13 + .../client/src/generated/mobpit/events.ts | 12 + .../client/src/generated/mobpit/surface.json | 100 ++++ .../client/src/generated/ripple/constants.ts | 20 +- .../client/src/generated/ripple/events.ts | 15 + .../client/src/generated/ripple/surface.json | 115 +++++ examples/game/client/src/leaderboard/main.ts | 8 +- examples/game/client/src/mobpit/main.ts | 63 ++- examples/game/client/src/ripple/main.ts | 2 +- examples/game/client/src/shared/hud.ts | 2 +- examples/game/client/src/shared/latest.ts | 2 +- .../game/client/tests/arena-headless.test.mjs | 105 ---- .../tests/mobpit-network-headless.test.mjs | 94 ++++ .../client/tests/prediction-wasm.test.mjs | 4 +- examples/game/client/vite.config.ts | 2 +- examples/game/run.sh | 6 +- examples/game/server/rooms/src/zone.rs | 8 +- examples/game/server/sim/src/mobpit.rs | 9 + examples/game/server/src/arena/mod.rs | 464 ------------------ examples/game/server/src/common/mod.rs | 2 +- examples/game/server/src/freeze/content.rs | 2 +- examples/game/server/src/freeze/mod.rs | 72 ++- examples/game/server/src/lib.rs | 3 +- examples/game/server/src/main.rs | 4 - examples/game/server/src/mobpit/routes.rs | 31 ++ examples/game/server/src/mobpit/store.rs | 32 ++ examples/game/server/src/ripple/mod.rs | 3 +- examples/game/server/tests/arena_smoke.rs | 196 -------- examples/game/server/tests/freeze_smoke.rs | 4 +- .../src/lib/generated/mobpit/constants.ts | 13 + .../src/lib/generated/mobpit/events.ts | 12 + .../src/lib/generated/mobpit/surface.json | 100 ++++ examples/game/sveltekit/src/lib/pit/loop.ts | 31 ++ .../src/routes/api/dev/netsim/+server.ts | 46 ++ .../sveltekit/src/routes/pit/+page.svelte | 55 ++- examples/game/sveltekit/tests/pit.test.mjs | 21 +- 55 files changed, 1091 insertions(+), 1962 deletions(-) delete mode 100644 examples/game/client/arena/index.html delete mode 100644 examples/game/client/src/arena/client.ts delete mode 100644 examples/game/client/src/arena/main.ts delete mode 100644 examples/game/client/src/generated/arena/client.ts delete mode 100644 examples/game/client/src/generated/arena/constants.ts delete mode 100644 examples/game/client/src/generated/arena/events.ts delete mode 100644 examples/game/client/src/generated/arena/index.ts delete mode 100644 examples/game/client/src/generated/arena/input.ts delete mode 100644 examples/game/client/src/generated/arena/predict.ts delete mode 100644 examples/game/client/src/generated/arena/schema.ts delete mode 100644 examples/game/client/src/generated/arena/surface.json delete mode 100644 examples/game/client/tests/arena-headless.test.mjs create mode 100644 examples/game/client/tests/mobpit-network-headless.test.mjs delete mode 100644 examples/game/server/src/arena/mod.rs delete mode 100644 examples/game/server/tests/arena_smoke.rs create mode 100644 examples/game/sveltekit/src/routes/api/dev/netsim/+server.ts diff --git a/examples/game/README.md b/examples/game/README.md index b2cbfcec..35249c3e 100644 --- a/examples/game/README.md +++ b/examples/game/README.md @@ -8,8 +8,7 @@ guests and lets any page change the network simulation. | Demo | Run | Mechanic it demonstrates | Durable state | |---|---|---|---| -| **Arena** | `./run.sh arena` | Fixed 30 Hz tick / 20 Hz patches, binary deltas, interest management (view ring, drones pop in/out), client prediction + reconciliation (the Rust movement step compiled to WebAssembly, bit-exact), interpolation of remote entities, live latency/jitter/loss | none (stateless: positions only live in the zone) | -| **Mob Pit** | `./run.sh mobpit` (`--fleet` for a local multi-node celld) | A near-infinite seeded grid of rooms joined by doorways, **each room a celld Durable Object** (celld places, forwards, hibernates and recovers rooms; the debug overlay shows the node); authoritative melee combat vs. telegraphing mobs; kills are **non-blocking durable facts** settled exactly once in per-player cells; rooms you leave go dormant then asleep (live minimap from the zone status read model) and come back with their mobs | celld: `pit..` rooms (snapshots), `crawler/{player}`, `presence/{player}`, `zone-activity/{room}`; crawler list and room status = read models projected from the cells' events | +| **Mob Pit** | `./run.sh mobpit` (`--fleet` for a local multi-node celld) | A near-infinite seeded grid of rooms joined by doorways, **each room a celld Durable Object** (celld places, forwards, hibernates and recovers rooms; the debug overlay shows the node); authoritative melee combat vs. telegraphing mobs; a **network panel** (latency / jitter / loss applied by every room object on celld, client prediction of the Rust step as WASM with reconciliation, a server ghost, an interest ring: what is outside is not replicated); kills are **non-blocking durable facts** settled exactly once in per-player cells; rooms you leave go dormant then asleep (live minimap from the zone status read model) and come back with their mobs | celld: `pit..` rooms (snapshots), `crawler/{player}`, `presence/{player}`, `zone-activity/{room}`; crawler list and room status = read models projected from the cells' events | | **Leaderboard** | `./run.sh leaderboard` | Same setup as Mob Pit: kills/XP sorted-set boards (in-memory or Redis) fed from the projected crawler list with idempotent `max` updates, your rank + neighbours, rebuild from the cells' event history | celld (the Mob Pit cells); boards are a projection (memory or Redis) | | **Shooting Gallery** | `./run.sh gallery` | Lag compensation: hitscan shots resolved with `ctx.rewind().hit_test_shot` at the shooter's view time (`client_tick` + interpolation delay in the payload), `max_rewind` clamp, per-shot compensation on/off, predicted vs confirmed hits | none (stateless: score is per session) | | **The Freeze** | `./run.sh freeze` | World-version barrier (`WorldCoordinator::rollout`) across **two server processes** as an in-world "admin update": freeze → apply → resume with change notes, inputs dropped while frozen, Hard reload vs safe-room gating (`StalePolicy::SafeRoom`, `Zone::is_gate`) of old clients, aborted rollout (`freeze_ready` → `NotAcked`), facts/events stamped with the content version | celld: `gem-pouch/{player}`, `content-log/{zone}` | @@ -23,14 +22,14 @@ list` lists demos. **How the demos are written.** Each zone describes its world with the `distributed_game` authoring derives: `#[derive(Replicated)]` view structs -for the kinds (`arena::PlayerView`, `mobpit::zone::MobView`, …), -`#[derive(GameInput)]` for the input frame (`arena::movement::MoveInput`, +for the kinds (`mobpit::zone::PlayerView`, `mobpit::zone::MobView`, …), +`#[derive(GameInput)]` for the input frame (`game_sim::mobpit::PitInput`, `gallery::ShotInput`, …), `#[derive(GameEvent)]` enums for events (`mobpit::zone::PitEvent`) and `#[derive(FactCommand)]` on the cell command inputs they emit as facts (`mobpit::crawler::RecordKillInput`). The movement that clients predict is declared once with `#[predict]` in the `game_sim` crate (`server/sim/`, no server dependencies): -`arena_step` (Arena, The Freeze), `pit_step` (Mob Pit), `hood_step` (Boss +`arena_step` (The Freeze), `pit_step` (Mob Pit), `hood_step` (Boss Ripple) and `grid_step` (Endless Grid, whose context is the world seed and cell); the zones call those functions in `on_tick`. Each demo module exports `game_surface()`; `run.sh gen` runs `distributed game-manifest` on @@ -46,9 +45,9 @@ typed field accessors (`mobFields(entity).hp`), the input encoder and typed No simulation or content code is written twice. Movement is the compiled step; the Endless Grid's terrain, biomes and props come from the compiled `grid_cell` / `grid_sample` (`createGridPures`); static data the pages -draw is a generated constant from the Rust values (`ARENA_FLOOR` — floor -size, pillars, capsule radius, used by every Arena-floor page — -`GRID_WORLD`, `FREEZE_WORLD`, `PIT_RULES` tier radii, `RIPPLE_WORLD` zone +draw is a generated constant from the Rust values (`PIT_WORLD` — room +size, doorways, pillars, capsule radius —, `ARENA_FLOOR` for The Freeze and +Boss Ripple, `GRID_WORLD`, `FREEZE_WORLD`, `PIT_RULES` tier radii, `RIPPLE_WORLD` zone hierarchy, `GALLERY_COURSE` rails); The Freeze's content packs are a Rust record served in its payload encoding and decoded by the generated `decodePack`. What TypeScript still mirrors is small and listed under @@ -63,23 +62,28 @@ test imports natively). ## Run ```sh -examples/game/run.sh arena -# → open http://localhost:5173/arena/ (?name=Ana&zone=arena-2 optional) +examples/game/run.sh mobpit +# → open http://localhost:5173/mobpit/ (?name=Ana&zone=pit.1.0 optional) ``` The script builds the JS SDK if `js/dist` is stale, runs `npm ci` for the -client, builds `game-demos`, starts it on `127.0.0.1:2567` with `--dev`, then -runs Vite (which proxies `/zones` (WebSocket), `/dev` and `/metrics` to the -server). Ports: `GAME_DEMOS_PORT`, `GAME_DEMOS_CLIENT_PORT`. Open a second tab -to see another player interpolated. Server metrics: `/metrics`. - -Arena controls: WASD / arrows / left stick to move (camera-relative, drag to -orbit), Space / Shift / gamepad A to dash. **P** toggles client prediction -(off = your capsule is drawn like everyone else's, one RTT + 100 ms behind -your keys), **G** the wireframe ghost at the latest server position. The -NetSim sliders change the server's simulated one-way latency (applied in -both directions), jitter and loss (a TCP-like retransmit delay) for every -connection. +client, builds `game-demos` and the cells worker, starts `celld dev` (the +rooms), the services on `127.0.0.1:2567` with `--dev`, then runs Vite (which +proxies `/rooms` (WebSocket) to celld and `/dev` to the services). Ports: +`GAME_DEMOS_PORT`, `GAME_DEMOS_CLIENT_PORT`. Open a second browser (or +`?player=two`) to see another player interpolated. + +Network panel (Mob Pit's HUD): **P** toggles client prediction (off = your +capsule is drawn like everyone else's, one RTT + the interpolation delay +behind your keys), **G** the wireframe ghost at the room's latest +authoritative position; the readout shows pending inputs, the last acked +input and corrections. The NetSim sliders set the simulated one-way latency +(applied in both directions), jitter and loss (a TCP-like retransmit delay) +for **every room on celld**: `/dev/netsim` writes the cells worker's dev +settings object (`zone-dev/netsim`), every room object reads it within a +second and delays its sockets' frames and inputs (`distributed_game::netsim`, +`ZONE_DEV_SETTINGS=1`). The dashed ring is your interest radius +(`hello.viewRadius`, 24 m): mobs and players outside it are not replicated. Shooting Gallery controls: click a target (or Space / gamepad A through the crosshair), drag to look. **C** toggles lag compensation (sent with every @@ -364,16 +368,16 @@ errors. │ GameClient (SDK) │ ───────────────► │ │ guest principal (server-made) │ │ ├ GameConnection ws ───────┼─ /zones/{zone} ─►│ │ /dev/netsim → set_net_sim │ │ ├ EntityReplica (interp.) │ ◄─ Welcome/Delta │ └ GameServer::serve_with │ - │ └ Predictor ── arena.wasm │ │ ZoneHost 30 Hz │ + │ └ Predictor ── mobpit.wasm │ │ ZoneHost 30 Hz │ │ src/shared/ hud · input · scene │ │ └ game_sim::arena::arena_step │ - │ src/generated/arena (typed glue) │ GET /metrics │ GET /metrics (Prometheus) │ - │ └ wasm/arena.wasm ◄═════════════╪══ distributed game-wasm (same Rust, wasm32) ══╝ │ + │ src/generated/ (typed glue) │ GET /metrics │ GET /metrics (Prometheus) │ + │ └ wasm/.wasm ◄════════════╪══ distributed game-wasm (same Rust, wasm32) ══╝ │ └──────────────────────────────────┘ └─────────────────────────────────────┘ ``` Prediction is exact because (1) every input is one command frame of exactly one tick (sent by the SDK's `InputPacer`), consumed one per tick with a small -credit bank (`distributed_game::InputCredits`, `arena::MAX_CREDITS`), (2) the +credit bank (`distributed_game::InputCredits`, a room's `MAX_CREDITS`), (2) the client runs the server's own Rust step compiled to WebAssembly, over a binary ABI that keeps every binary32 bit, and the step uses only operations that round identically on every target (`+ - * /`, `sqrt`, comparisons; no @@ -392,7 +396,7 @@ What the TypeScript still restates, and why it stays: - **Zone id formats and defaults** — `grid..` (`zoneId` / `parseZone` in `grid/client.ts`) and each page's default zone - (`arena-1`, `pit-1`, …). They are addressing, needed synchronously before + (`pit.0.0`, `freeze-hub`, …). They are addressing, needed synchronously before any module loads; a mismatch fails loudly (the server rejects the zone). - **Dev / admin HTTP JSON shapes** — `WorldMap`, `WorldStatus`, `BoardPosition`: debugging routes outside the game surface. @@ -410,7 +414,8 @@ examples/game/ scripts/round-robin.mjs dev TCP balancer in front of a local celld fleet (`run.sh mobpit --fleet`) cells/ crate `game-demo-cells` (own workspace, wasm32, worker-build): Mob Pit's rooms (`PitRoom`, one Durable Object per room) and the demos' - celld cells — Crawler + presence + zone activity (Mob Pit), gem + celld cells — Crawler + presence + zone activity + dev settings + (`ZoneDevObject`: the rooms' NetSim) (Mob Pit), gem pouch + content log (The Freeze), zone lease + zone state (multi-host demos), and the `game-demo-outbox` Queue consumer (relay) server/ Rust crate `game_demos`, its own cargo workspace (with sim/, @@ -436,15 +441,13 @@ examples/game/ zone_status.rs (zone lease/snapshot/activity status read model) src/devcelld.rs `celld dev` harness for tests (own project, port, process tree) src/cluster.rs multi-host demos: --host-id/--peer, directory, ownership, snapshots - src/arena/ Arena zone (`movement` = `game_sim::arena`) src/mobpit/ Mob Pit services (the rooms run on celld): dev room tickets, relay, crawler directory and room status projections, leaderboards src/gallery/ Shooting Gallery zone (targets, rewind hit tests) src/freeze/ The Freeze zone, content packs, two-host world + admin routes src/grid/ Endless Grid zone (`world` = `game_sim::grid`), dev map routes src/ripple/ Boss Ripple tiers and placement - tests/ arena_smoke.rs (bots, dev routes), - mobpit_smoke.rs (facts, rooms on celld, celld crash, boards, presence), + tests/ mobpit_smoke.rs (facts, rooms on celld, celld crash, boards, presence), gallery_smoke.rs, freeze_smoke.rs (binary content packs), grid_smoke.rs, ripple_smoke.rs (+ support/ for two in-process hosts) sveltekit/ SvelteKit app over the Mob Pit server + mobpit-service (see SvelteKit above) @@ -467,14 +470,14 @@ examples/game/ wasm/.wasm (gitignored build output); `mobpit` also serves the leaderboard page src/shared/ hud.ts, input.ts, scene.ts, dev-api.ts, latest.ts, style.css - src/arena/ client.ts (headless), main.ts (Babylon) src/mobpit/ client.ts (headless + /dev/mobpit API), main.ts (Babylon), style.css src/leaderboard/ main.ts (DOM page over the observer connection), style.css src/gallery/ client.ts (shots, adaptive delay), main.ts src/freeze/ client.ts (content packs, live patch / reload, admin API), main.ts src/grid/, src/ripple/ client.ts (headless; grid: compiled world content), main.ts (Babylon) tests/ prediction-wasm (native ↔ WASM exact match of steps + pure functions, cost), - arena-headless, mobpit-headless, leaderboard-headless, + mobpit-headless, mobpit-network-headless (NetSim on celld rooms, + exact prediction, interest), mobpit-restarts-headless, leaderboard-headless, gallery-headless, freeze-headless, grid-headless, ripple-headless (+ helpers/demo-server.mjs, helpers/mobpit.mjs, helpers/cluster.mjs for multi-host demos, helpers/celld.mjs for @@ -503,9 +506,10 @@ evicted when idle, moved when idle, recovered elsewhere if its node crashes. The game never names a node; the debug overlay only *shows* the one celld chose. -Controls: WASD / stick move (the Arena's movement, predicted by `pit_step`, -same floor and pillars), **Space** attack (120° cone, 2.2 m), **E** dash, -**O** debug overlay. Walk through a **doorway** — the glowing gap in the +Controls: WASD / stick move (predicted by `pit_step`, the Rust step as +WASM), **Space** attack (120° cone, 2.2 m), **E** dash, **O** debug +overlay, **P** prediction, **G** server ghost (see the network panel under +[Run](#run)). Walk through a **doorway** — the glowing gap in the middle of each wall (go beside the pillar on its axis) — into the neighbouring room. The rooms are **one continuous world**: room `(x, y)` sits at world `(x, y) × 40 m` (they share walls), the rooms around yours @@ -626,7 +630,7 @@ counts exactly 200 in the cell. version pinned in the repository's `.celld-version` (`CELLD_BIN`, default `celld` on PATH; `scripts/celld.sh check` verifies it and prints the install command) and `worker-build` (`cargo install worker-build --locked`) for -`cells/`. Only `arena` and `gallery` run without celld. The tests each +`cells/`. Only `gallery` runs without celld. The tests each start their own `celld dev` on a temp project (`game_demos::devcelld`, `client/tests/helpers/celld.mjs`); the SvelteKit Playwright test starts a 3-node fleet (`client/tests/helpers/fleet.mjs`) when docker and the aws CLI diff --git a/examples/game/cells/src/lib.rs b/examples/game/cells/src/lib.rs index fe4505f4..fdc18ead 100644 --- a/examples/game/cells/src/lib.rs +++ b/examples/game/cells/src/lib.rs @@ -5,6 +5,9 @@ //! `distributed_game::celld_zone` (`mobpit_rooms::MobPit`). celld places //! each room; its kill and lifecycle facts reach the Crawler and presence //! cells below object to object, exactly once. +//! - `zone-dev/{shard}` (`ZoneDevObject`, dev only): settings every room +//! reads (`ZONE_DEV_SETTINGS=1`): the network simulation of the HUD's +//! sliders. //! - `zone-activity/{room}` (`ZoneActivityObject`): each room's lifecycle //! (active / dormant / hibernated); its `zone.activity_changed` events //! leave through the outbox Queue for the zone status read model. @@ -58,7 +61,7 @@ use distributed::microsvc::{ }; use distributed::{sourced, Aggregate, Entity}; use distributed_game::cell::{fact_session_from_headers, session_stamp}; -use distributed_game::celld_zone::{forward_to_zone, ZoneActivityCell}; +use distributed_game::celld_zone::{forward_to_zone, ZoneActivityCell, ZoneDevSettings}; use distributed_game::lifecycle::{ Lifecycle, CONNECTION_LOST, PLAYER_DISCONNECTED, PLAYER_JOINED, PLAYER_LEFT, PLAYER_RECONNECTED, }; @@ -732,6 +735,26 @@ impl DurableObject for ZoneStateCell { distributed_game::celld_zone_object!(PitRoom, mobpit_rooms::MobPit); +/// The rooms' dev settings (`zone-dev/{shard}`: the network simulation the +/// rooms apply, written by the services' `/dev/netsim`). +#[durable_object] +pub struct ZoneDevObject { + dev: ZoneDevSettings, +} + +impl DurableObject for ZoneDevObject { + fn new(state: State, env: Env) -> Self { + console_error_panic_hook::set_once(); + Self { + dev: ZoneDevSettings::new(state, env), + } + } + + async fn fetch(&self, req: Request) -> Result { + self.dev.fetch(req).await + } +} + #[durable_object] pub struct ZoneActivityObject { cell: ZoneActivityCell, @@ -782,6 +805,7 @@ async fn main(req: Request, env: Env, _ctx: Context) -> Result { Some("zone-lease") => "ZONE_LEASE", Some("zone-state") => "ZONE_STATE", Some(ZONE_ACTIVITY_KIND) => "ZONE_ACTIVITY", + Some(distributed_game::netsim::ZONE_DEV_KIND) => "ZONE_DEV", _ => return json_status(json!({ "error": "unknown cell kind" }), 404), }; let Some(shard) = parts.get(1) else { diff --git a/examples/game/cells/wrangler.jsonc b/examples/game/cells/wrangler.jsonc index e18bdd9b..d15a8c40 100644 --- a/examples/game/cells/wrangler.jsonc +++ b/examples/game/cells/wrangler.jsonc @@ -16,14 +16,16 @@ { "name": "GEM_POUCH", "class_name": "GemPouchCell" }, { "name": "CONTENT_LOG", "class_name": "ContentLogCell" }, { "name": "PIT_ROOM", "class_name": "PitRoom" }, - { "name": "ZONE_ACTIVITY", "class_name": "ZoneActivityObject" } + { "name": "ZONE_ACTIVITY", "class_name": "ZoneActivityObject" }, + { "name": "ZONE_DEV", "class_name": "ZoneDevObject" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["CrawlerCell", "PresenceCell"] }, { "tag": "v2", "new_sqlite_classes": ["ZoneLeaseCell", "ZoneStateCell"] }, { "tag": "v3", "new_sqlite_classes": ["GemPouchCell", "ContentLogCell"] }, - { "tag": "v4", "new_sqlite_classes": ["PitRoom", "ZoneActivityObject"] } + { "tag": "v4", "new_sqlite_classes": ["PitRoom", "ZoneActivityObject"] }, + { "tag": "v5", "new_sqlite_classes": ["ZoneDevObject"] } ], // Hibernation snapshots above the inline limit (celld's object storage). "r2_buckets": [ @@ -52,6 +54,9 @@ "ZONE_PATCH_HZ": "20", "ZONE_PERSIST_MS": "5000", "ZONE_HIBERNATE_AFTER_MS": "10000", - "ZONE_FACT_SERVICE": "mobpit-rooms" + "ZONE_FACT_SERVICE": "mobpit-rooms", + // Dev demo: rooms apply the network simulation of the HUD sliders + // (`zone-dev/netsim`, written by the services' `/dev/netsim`). + "ZONE_DEV_SETTINGS": "1" } } diff --git a/examples/game/client/arena/index.html b/examples/game/client/arena/index.html deleted file mode 100644 index 1b3276c8..00000000 --- a/examples/game/client/arena/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Arena Demo - - - -
- - - diff --git a/examples/game/client/index.html b/examples/game/client/index.html index 4615fc99..b8dcef68 100644 --- a/examples/game/client/index.html +++ b/examples/game/client/index.html @@ -11,7 +11,6 @@

distributed_game demos

Each demo runs its own server: examples/game/run.sh <demo>. The page for the running demo works; the others need their server.

    -
  • Arena — fixed tick, binary deltas, interest management, prediction + reconciliation, interpolation.
  • Mob Pit — authoritative combat, non-blocking durable facts into per-player celld cells, slow storage, crash recovery.
  • Leaderboard — kills/XP sorted-set boards projected from the cells, pushed live, rebuilt from events (runs with Mob Pit).
  • Shooting Gallery — lag compensation: hitscan shots rewound to the shooter's view time, max-rewind clamp, compensation on/off.
  • diff --git a/examples/game/client/src/arena/client.ts b/examples/game/client/src/arena/client.ts deleted file mode 100644 index 157e29ef..00000000 --- a/examples/game/client/src/arena/client.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Arena networking without rendering: the generated `createArenaClient` - * (input encoder, correction defaults, typed events and prediction with the - * Rust movement step compiled to WebAssembly) plus dev tickets; binds the - * own capsule from the zone's typed `hello`. Used by `main.ts` (Babylon) - * and the headless test. - */ -import type { GameSocketFactory } from '@hops-ops/distributed/game'; - -import * as arena from '../generated/arena/index.ts'; -import { zoneUrl, type DevApi } from '../shared/dev-api.ts'; -import { latest } from '../shared/latest.ts'; - -export const DEFAULT_ZONE = 'arena-1'; - -export type ArenaClient = arena.ArenaClient & - Readonly<{ - /** Latest hello (own entity id, view radius); undefined until joined. */ - hello(): arena.HelloEvent | undefined; - /** Resolves on the first hello, once the predicted step is loaded. */ - ready: Promise; - }>; - -export type ArenaClientOptions = Readonly<{ - api: DevApi; - /** HTTP origin of the server (Node); default: the page origin. */ - base?: string; - socket?: GameSocketFactory; -}>; - -export function createArenaClient(options: ArenaClientOptions): ArenaClient { - const typed = arena.createArenaClient({ - url: (zone) => zoneUrl(zone, options.base), - ticket: options.api.ticketSource(), - socket: options.socket, - pingIntervalMs: 500, - // One input = one 30 Hz command frame on the server. - inputDtMs: 1000 / 30 - // Prediction: the generated default, `arena_step` compiled to WASM. - }); - const hello = latest( - (h) => typed.on('hello', h), - (value) => typed.client.setControlledEntity(value.entity) - ); - const ready = Promise.all([hello.ready, typed.prediction?.ready]).then(([h]) => h); - return { ...typed, hello: hello.get, ready }; -} diff --git a/examples/game/client/src/arena/main.ts b/examples/game/client/src/arena/main.ts deleted file mode 100644 index 1fceb6d2..00000000 --- a/examples/game/client/src/arena/main.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * Arena demo page: Babylon scene over `createArenaClient`. - * - * - own capsule: predicted (inputs apply immediately), corrections eased; - * toggle prediction off (P) to see raw input latency (RTT + interpolation - * delay); the ghost (G) shows the latest authoritative server position; - * - other players and drones: interpolated ~100 ms in the past; - * - interest ring: drones beyond it are removed by the server (they shrink - * away over the last few metres before the edge); players and pillars are - * global, so everyone in the room stays visible (dimmed outside the ring); - * - minimap (top right): everyone's position and the view ring. - */ -import type { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial'; -import { Vector3 } from '@babylonjs/core/Maths/math.vector'; -import { Mesh } from '@babylonjs/core/Meshes/mesh'; -import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder'; -import type { Scene } from '@babylonjs/core/scene'; -import { InputPacer, type RenderedEntity } from '@hops-ops/distributed/game'; - -import { DevApi } from '../shared/dev-api.ts'; -import { Hud } from '../shared/hud.ts'; -import { cameraRelative, describeGamepad, InputSampler, toAxis } from '../shared/input.ts'; -import { Minimap, type MinimapDot } from '../shared/minimap.ts'; -import { createRing, createStage, material, toWorld } from '../shared/scene.ts'; -import { droneFields, isDrone, isPillar, pillarFields, playerFields, BUTTON, type MoveInput } from '../generated/arena/index.ts'; -import { createArenaClient, DEFAULT_ZONE } from './client.ts'; -import { ARENA_FLOOR } from '../generated/arena/constants.ts'; - -/** The Arena floor, generated from the simulation's constants (`game_sim::arena::arena_floor`). */ -const { half: HALF, playerRadius: PLAYER_RADIUS, dashCooldown: DASH_COOLDOWN } = ARENA_FLOOR; - -const params = new URLSearchParams(location.search); -const zone = params.get('zone') ?? DEFAULT_ZONE; -const storedName = (() => { - try { - return localStorage.getItem('game-demos.name') ?? undefined; - } catch { - return undefined; - } -})(); -const name = params.get('name') ?? storedName; - -const api = new DevApi({ name: () => name }); -const arena = createArenaClient({ api }); -const { client } = arena; - -const canvas = document.getElementById('stage') as HTMLCanvasElement; -const stage = createStage(canvas, { half: HALF }); -const { scene } = stage; - -let ghostOn = true; -let corrections = 0; -let lastCorrection = 0; -client.on('correction', (c) => { - corrections += 1; - lastCorrection = Math.hypot(c.errors.x ?? 0, c.errors.y ?? 0); -}); - -const hud = new Hud({ - title: 'Arena', - hint: 'WASD / stick to move, Space / A to dash. P: prediction, G: ghost, V: first person (click to look). Right stick / drag: camera.', - client, - devApi: api -}); -hud.toggle('Client prediction', true, (on) => client.setPredictionEnabled(on), 'p'); -hud.toggle('Server ghost', true, (on) => (ghostOn = on), 'g'); -hud.toggle('First-person view', false, (on) => stage.setView(on ? 'first' : 'third'), 'v'); - -// ---- meshes ---- -const mats = { - me: material(scene, 'me', '#6cc4ff'), - other: material(scene, 'other', '#f2a65a'), - otherFar: material(scene, 'other-far', '#f2a65a', 0.45), - ghost: material(scene, 'ghost', '#ffffff', 0.25), - drone: material(scene, 'drone', '#9ad16b'), - pillar: material(scene, 'pillar', '#7b8798') -}; -mats.ghost.wireframe = true; - -function capsule(id: string, mat: StandardMaterial): Mesh { - const mesh = MeshBuilder.CreateCapsule(id, { radius: PLAYER_RADIUS, height: 1.8 }, scene); - mesh.material = mat; - return mesh; -} - -const droneTemplate = (() => { - const body = MeshBuilder.CreateSphere('drone-body', { diameter: 0.7, segments: 8 }, scene); - body.scaling.y = 0.45; - const nose = MeshBuilder.CreateBox('drone-nose', { width: 0.5, height: 0.12, depth: 0.12 }, scene); - nose.position.x = 0.35; - const merged = Mesh.MergeMeshes([body, nose], true)!; - merged.material = mats.drone; - merged.setEnabled(false); - return merged; -})(); - -type VisualKind = 'player' | 'drone' | 'pillar'; -type Visual = { mesh: Mesh | ReturnType; kind: VisualKind }; -const visuals = new Map(); -const ghost = capsule('ghost', mats.ghost); -const ring = createRing(scene, 14, '#6cc4ff'); -ring.setEnabled(false); - -/** Drones shrink away over this many metres inside the view-radius edge. */ -const FADE_BAND = 3; -const COLORS = { me: '#6cc4ff', other: '#f2a65a', drone: '#9ad16b', pillar: '#7b8798' }; -const minimap = new Minimap({ - half: HALF, - legend: [ - { color: COLORS.me, label: 'you' }, - { color: COLORS.other, label: 'players (global)' }, - { color: COLORS.drone, label: 'drones (in view only)' } - ] -}); - -function visualFor(entity: RenderedEntity, mine: boolean): Visual { - let visual = visuals.get(entity.key); - if (visual !== undefined) return visual; - if (isDrone(entity)) { - visual = { mesh: droneTemplate.createInstance(entity.key), kind: 'drone' }; - } else if (isPillar(entity)) { - const { r } = pillarFields(entity); - const mesh = MeshBuilder.CreateCylinder(entity.key, { diameter: r * 2, height: 2, tessellation: 24 }, scene); - mesh.material = mats.pillar; - visual = { mesh, kind: 'pillar' }; - } else { - visual = { mesh: capsule(entity.key, mine ? mats.me : mats.other), kind: 'player' }; - } - visuals.set(entity.key, visual); - return visual; -} - -// ---- input: one command frame per 30 Hz tick ---- -const sampler = new InputSampler(); -const commandFrames = new InputPacer({ tickRate: () => client.authority?.connection.welcome?.tickRate }); -let lastInput: MoveInput = { mx: 0, my: 0, buttons: 0 }; - -function sendCommandFrame(): void { - const s = sampler.sample(); - const move = cameraRelative(s, stage.cameraForward()); - lastInput = { mx: toAxis(move.x), my: toAxis(move.y), buttons: s.action ? BUTTON.dash : 0 }; - client.sendInput(lastInput); -} - -// ---- frame ---- -stage.onFrame((now, dtMs) => { - commandFrames.update(now, sendCommandFrame); - const hello = arena.hello(); - const authority = client.authority; - const rendered = client.render(); - const seen = new Set(); - const isMine = (entity: RenderedEntity) => - authority !== undefined && hello !== undefined && entity.zoneId === authority.zoneId && entity.id === hello.entity; - const mineEntity = rendered.find(isMine); - let me: { x: number; y: number } | undefined = mineEntity === undefined ? undefined : playerFields(mineEntity); - const viewRadius = hello?.viewRadius ?? 14; - const dots: MinimapDot[] = []; - for (const entity of rendered) { - const mine = isMine(entity); - const visual = visualFor(entity, mine); - seen.add(entity.key); - const distanceTo = (x: number, y: number) => (me === undefined ? 0 : Math.hypot(x - me.x, y - me.y)); - if (visual.kind === 'drone') { - const { x, y, yaw } = droneFields(entity); - // Shrink toward the edge so culling at the ring reads as "out of view". - const s = Math.min(1, Math.max(0.2, (viewRadius - distanceTo(x, y)) / FADE_BAND)); - visual.mesh.position.copyFrom(toWorld(x, y, 1.3)); - visual.mesh.rotation.y = -yaw; - visual.mesh.scaling.setAll(s); - dots.push({ x, y, color: COLORS.drone, size: 2 }); - } else if (visual.kind === 'pillar') { - const { x, y, r } = pillarFields(entity); - visual.mesh.position.copyFrom(toWorld(x, y, 1)); - dots.push({ x, y, color: COLORS.pillar, size: Math.max(2, r * 2), hollow: true }); - } else { - // Prediction off (P): the SDK renders the own capsule like everyone - // else's, interpolated in the past. - const { x, y, dash, name: label } = playerFields(entity); - const distance = distanceTo(x, y); - visual.mesh.position.copyFrom(toWorld(x, y, 0.9)); - const dashing = dash > 0; - visual.mesh.scaling.copyFromFloats(dashing ? 1.25 : 1, 1, dashing ? 1.25 : 1); - const outside = !mine && distance > viewRadius; - if (!mine) (visual.mesh as Mesh).material = outside ? mats.otherFar : mats.other; - const text = mine ? `${label} (you)` : outside ? `${label} · outside view` : label; - // First person: the camera is the own capsule, so hide it and its label. - if (mine && stage.view() === 'first') stage.labels.delete(entity.key); - else stage.labels.set(entity.key, visual.mesh as Mesh, text, 1.4, mine ? 'me' : ''); - if (mine) me = { x, y }; - dots.push({ x, y, color: mine ? COLORS.me : COLORS.other, size: mine ? 4 : 3.5 }); - } - visual.mesh.setEnabled((!entity.stale || visual.kind === 'pillar') && !(mine && stage.view() === 'first')); - } - for (const [key, visual] of visuals) { - if (seen.has(key)) continue; - visual.mesh.dispose(); - stage.labels.delete(key); - visuals.delete(key); - } - - const authoritative = client.predictor?.authoritative(); - let ghostVisible = ghostOn && authoritative !== undefined; - if (authoritative !== undefined) { - const { x: gx, y: gy } = playerFields({ fields: authoritative }); - ghost.position.copyFrom(toWorld(gx, gy, 0.9)); - // First person: the eye sits inside the ghost; show it only once it lags visibly. - if (stage.view() === 'first' && me !== undefined && Math.hypot(gx - me.x, gy - me.y) < 0.8) ghostVisible = false; - } - ghost.setEnabled(ghostVisible); - if (me !== undefined) { - stage.follow(me.x, me.y, dtMs); - ring.setEnabled(true); - ring.position = new Vector3(me.x, 0, me.y); - if (hello !== undefined && ring.metadata !== hello.viewRadius) { - ring.scaling.setAll(hello.viewRadius / 14); - ring.metadata = hello.viewRadius; - } - } - - minimap.draw({ me, viewRadius, dots }); - hud.frame({ entities: rendered.length }); - const predictor = client.predictor; - const cd = authoritative === undefined ? 0 : playerFields({ fields: authoritative }).cd; - hud.row('You', api.last === undefined ? '—' : `${api.last.name} · ${api.last.principal}`); - hud.row('Pending inputs', String(predictor?.pending.length ?? 0)); - hud.row('Ack seq', String(predictor?.lastAck ?? 0)); - hud.row('Corrections', `${corrections} (last ${lastCorrection.toFixed(3)} m)`); - hud.row('Dash', cd > 0 ? `cooldown ${((cd / DASH_COOLDOWN) * 1.5).toFixed(1)} s` : 'ready'); - hud.row('Stick', `${lastInput.mx}, ${lastInput.my}`); - hud.row('Gamepad', describeGamepad()); -}); - -declare global { - interface Window { - /** Debug handle (console). */ - arena?: unknown; - } -} -window.arena = { client, api, prediction: arena.prediction, scene: scene as Scene }; - -client.connect(zone).catch((error: unknown) => hud.row('Status', `failed: ${String(error)}`)); diff --git a/examples/game/client/src/freeze/main.ts b/examples/game/client/src/freeze/main.ts index e8a4ae94..5dadb159 100644 --- a/examples/game/client/src/freeze/main.ts +++ b/examples/game/client/src/freeze/main.ts @@ -13,7 +13,7 @@ import type { Mesh } from '@babylonjs/core/Meshes/mesh'; import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder'; import { InputPacer, type RenderedEntity } from '@hops-ops/distributed/game'; -import { ARENA_FLOOR } from '../generated/arena/constants.ts'; +import { ARENA_FLOOR } from '../generated/freeze/constants.ts'; import { BUTTON, FREEZE_WORLD, KIND, gemFields, playerFields, ritualFields, slimeFields, type KindName, type MoveInput } from '../generated/freeze/index.ts'; import { DevApi } from '../shared/dev-api.ts'; import { Hud } from '../shared/hud.ts'; diff --git a/examples/game/client/src/generated/arena/client.ts b/examples/game/client/src/generated/arena/client.ts deleted file mode 100644 index 35174cb1..00000000 --- a/examples/game/client/src/generated/arena/client.ts +++ /dev/null @@ -1,68 +0,0 @@ -// @generated by `distributed game-client` from game surface `arena` (schema v3, protocol v1). -// Typed client factory. -// Do not edit: change the Rust definitions and regenerate. -import { GameClient, mergeCorrections, type GameClientOptions, type GamePredictionOptions, type WasmSource } from '@hops-ops/distributed/game'; - -import { EVENTS, type Events } from './events.ts'; -import { encodeInput, type MoveInput } from './input.ts'; -import { createArenaPrediction, type ArenaPrediction } from './predict.ts'; -import { CORRECTIONS } from './schema.ts'; - -/** - * Prediction tuning; `encodeInput`, the correction defaults and `predict` (the - * compiled `#[predict]` step) are filled in. A `predict` given here replaces - * the WebAssembly step. - */ -export type ArenaPredictionOptions = Omit, 'encodeInput' | 'predict'> & - Partial, 'encodeInput' | 'predict'>>; - -export type ArenaClientOptions = Omit, 'prediction'> & - Readonly<{ - /** Prediction tuning, or `false` for none. Default: the compiled step. */ - prediction?: ArenaPredictionOptions | false; - /** Where to load the compiled step from (default `PREDICT_WASM_URL`). */ - wasm?: WasmSource; - }>; - -export type ArenaClient = Readonly<{ - client: GameClient; - /** The compiled step (`ready`, `setContext`); undefined when prediction is off or custom. */ - prediction: ArenaPrediction | undefined; - /** Typed event subscription (every zone); returns the unsubscribe. */ - on(name: K, handler: (value: Events[K], zoneId: string) => void): () => void; - /** Send one input: predicted when prediction is on, raw otherwise. */ - send(input: MoveInput): number | undefined; -}>; - -/** - * A `GameClient` for the `arena` surface with its input encoder, correction - * defaults and the compiled predicted step wired in. - */ -export function createArenaClient(options: ArenaClientOptions): ArenaClient { - const { prediction: tuning, wasm, ...rest } = options; - const prediction = - tuning === false || tuning?.predict !== undefined ? undefined : createArenaPrediction(wasm); - const client = new GameClient({ - ...rest, - prediction: - tuning === false - ? undefined - : { - ...tuning, - predict: tuning?.predict ?? prediction!.predict, - encodeInput: tuning?.encodeInput ?? ((input) => encodeInput(input)), - corrections: mergeCorrections(CORRECTIONS, tuning?.corrections) - } - }); - return { - client, - prediction, - on: (name, handler) => - client.on('event', (zoneId, event) => { - const codec = EVENTS[name]; - if (event.typeId === codec.id) handler(codec.decode(event.payload), zoneId); - }), - send: (input) => - tuning === false ? client.sendRawInput(encodeInput(input)) : client.sendInput(input) - }; -} diff --git a/examples/game/client/src/generated/arena/constants.ts b/examples/game/client/src/generated/arena/constants.ts deleted file mode 100644 index 935e5326..00000000 --- a/examples/game/client/src/generated/arena/constants.ts +++ /dev/null @@ -1,22 +0,0 @@ -// @generated by `distributed game-client` from game surface `arena` (schema v3, protocol v1). -// Constants: static data defined once in Rust (`GameSurface::constant`). -// Do not edit: change the Rust definitions and regenerate. -import type { ArenaFloor } from './events.ts'; - -/** `arena_floor`, as the client decodes it. */ -export const ARENA_FLOOR: ArenaFloor = { - half: 20, - playerRadius: 0.5, - dashCooldown: 45, - pillars: [ - { x: 0, y: 0, r: 2.5 }, - { x: -9, y: -9, r: 1.5 }, - { x: 9, y: -9, r: 1.5 }, - { x: -9, y: 9, r: 1.5 }, - { x: 9, y: 9, r: 1.5 }, - { x: 0, y: -14, r: 1 }, - { x: 0, y: 14, r: 1 }, - { x: -14, y: 0, r: 1 }, - { x: 14, y: 0, r: 1 } - ] -}; diff --git a/examples/game/client/src/generated/arena/events.ts b/examples/game/client/src/generated/arena/events.ts deleted file mode 100644 index 76b8dadb..00000000 --- a/examples/game/client/src/generated/arena/events.ts +++ /dev/null @@ -1,79 +0,0 @@ -// @generated by `distributed game-client` from game surface `arena` (schema v3, protocol v1). -// Events and records: payload interfaces, ids, codecs, typed subscriptions. -// Do not edit: change the Rust definitions and regenerate. -import { decodeGameEvent, decodePayload, encodePayload, onGameEvent, type DecodedGameEvent, type GameEventCodecs, type GameEventSource, type PayloadLayout } from '@hops-ops/distributed/game'; - -/** Record `Pillar` (`#[derive(GameRecord)]`). */ -export interface Pillar { - readonly x: number; - readonly y: number; - readonly r: number; -} - -/** Record `ArenaFloor` (`#[derive(GameRecord)]`). */ -export interface ArenaFloor { - readonly half: number; - readonly playerRadius: number; - readonly dashCooldown: number; - readonly pillars: readonly Pillar[]; -} - -/** Record `MoveState` (`#[derive(GameRecord)]`). */ -export interface MoveState { - readonly x: number; - readonly y: number; - readonly vx: number; - readonly vy: number; - readonly dash: number; - readonly cd: number; -} - -/** Event `hello` (id 1). */ -export interface HelloEvent { - readonly entity: number; - readonly viewRadius: number; - readonly half: number; -} - -const HELLO_LAYOUT: PayloadLayout = [ - ['entity', { type: 'u32' }], - ['viewRadius', { type: 'f32' }], - ['half', { type: 'f32' }], -]; - -/** Event type ids by name. */ -export const EVENT = { - hello: 1, -} as const; - -/** Event payload types by name. */ -export interface Events { - hello: HelloEvent; -} - -/** Codecs of every event. */ -export const EVENTS: GameEventCodecs = { - hello: { - id: 1, - name: 'hello', - encode: (value) => encodePayload(HELLO_LAYOUT, value), - decode: (payload) => decodePayload(HELLO_LAYOUT, payload) as unknown as HelloEvent - }, -}; - -/** A decoded event, discriminated by `name`. */ -export type AnyEvent = DecodedGameEvent; - -/** Decode an Event message (`undefined` for ids this surface does not declare). */ -export function decodeEvent(event: { readonly typeId: number; readonly payload: Uint8Array }): AnyEvent | undefined { - return decodeGameEvent(EVENTS, event); -} - -/** Subscribe to one typed event on every zone of `client`; returns the unsubscribe. */ -export function onEvent( - client: GameEventSource, - name: K, - handler: (value: Events[K], zoneId: string) => void -): () => void { - return onGameEvent(client, EVENTS, name, handler); -} diff --git a/examples/game/client/src/generated/arena/index.ts b/examples/game/client/src/generated/arena/index.ts deleted file mode 100644 index e138f392..00000000 --- a/examples/game/client/src/generated/arena/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @generated by `distributed game-client` from game surface `arena` (schema v3, protocol v1). -// Everything generated for this surface. -// Do not edit: change the Rust definitions and regenerate. -export * from './client.ts'; -export * from './constants.ts'; -export * from './events.ts'; -export * from './input.ts'; -export * from './predict.ts'; -export * from './schema.ts'; diff --git a/examples/game/client/src/generated/arena/input.ts b/examples/game/client/src/generated/arena/input.ts deleted file mode 100644 index 5aa128cc..00000000 --- a/examples/game/client/src/generated/arena/input.ts +++ /dev/null @@ -1,51 +0,0 @@ -// @generated by `distributed game-client` from game surface `arena` (schema v3, protocol v1). -// Input frame: interface, button bits, encoder, decoder, sanitize. -// Do not edit: change the Rust definitions and regenerate. -import { decodePayload, encodePayload, sanitizePayload, type InputRule, type PayloadLayout } from '@hops-ops/distributed/game'; - -/** One `MoveInput` input frame (`#[derive(GameInput)]`). */ -export interface MoveInput { - readonly mx: number; - readonly my: number; - readonly buttons: number; -} - -export type Input = MoveInput; - -/** Bits of each bit-flag field (`#[buttons(..)]`), by field. */ -export const BITS = { - buttons: { - dash: 1, - }, -} as const; - -/** Bits of `buttons`, the only bit-flag field. */ -export const BUTTON = BITS.buttons; - -/** Wire layout, in field order. */ -export const INPUT_LAYOUT: PayloadLayout = [ - ['mx', { type: 'i8' }], - ['my', { type: 'i8' }], - ['buttons', { type: 'u8' }], -]; - -const INPUT_RULES: readonly InputRule[] = [ - { range: [-127, 127] }, - { range: [-127, 127] }, - { buttons: 1 }, -]; - -/** Payload bytes of one input frame. */ -export function encodeInput(input: MoveInput): Uint8Array { - return encodePayload(INPUT_LAYOUT, input); -} - -/** Decode one input frame (no sanitizing). */ -export function decodeInput(payload: Uint8Array): MoveInput { - return decodePayload(INPUT_LAYOUT, payload) as unknown as MoveInput; -} - -/** What the zone simulates for `input` (`#[range]` clamps, `#[buttons]` masks, wire precision). */ -export function sanitizeInput(input: MoveInput): MoveInput { - return sanitizePayload(INPUT_LAYOUT, INPUT_RULES, input)!; -} diff --git a/examples/game/client/src/generated/arena/predict.ts b/examples/game/client/src/generated/arena/predict.ts deleted file mode 100644 index 9d66870d..00000000 --- a/examples/game/client/src/generated/arena/predict.ts +++ /dev/null @@ -1,51 +0,0 @@ -// @generated by `distributed game-client` from game surface `arena` (schema v3, protocol v1). -// Client-predicted step: the Rust `#[predict]` fn compiled to WebAssembly (binary ABI). -// Do not edit: change the Rust definitions and regenerate. -import { createWasmPrediction, type PredictStateLayout, type WasmPrediction, type WasmSource } from '@hops-ops/distributed/game'; - -import { encodeInput, type MoveInput } from './input.ts'; - -/** The predicted step: `#[predict]` fn `arena_step` of Cargo package `game_sim`, predicting kind `player`. */ -export const PREDICT = { - kind: 'player', - export: 'arena_step', - package: 'game_sim', - state: 'MoveState', - abi: 1, -} as const; - -/** Predicted `player` fields (record `MoveState`): wire field name and codec type, in record order. */ -export const PREDICT_STATE: PredictStateLayout = [ - ['x', { type: 'f32' }], - ['y', { type: 'f32' }], - ['vx', { type: 'f32' }], - ['vy', { type: 'f32' }], - ['dash', { type: 'u8' }], - ['cd', { type: 'u8' }], -]; - -/** The step takes no context. */ -export type PredictContext = undefined; - -/** The step takes no context. */ -export const PREDICT_CONTEXT = undefined; - -/** The compiled step (`distributed game-wasm`); bundlers emit it as an asset. */ -export const PREDICT_WASM_URL: URL = new URL('./wasm/arena.wasm', import.meta.url); - -/** The `arena` step as `GameClient` prediction (see `createWasmPrediction`). */ -export type ArenaPrediction = WasmPrediction; - -/** - * Load the compiled step (default `PREDICT_WASM_URL`) as prediction: `predict` - * leaves the state unchanged until `ready` resolves. - */ -export function createArenaPrediction(source: WasmSource = PREDICT_WASM_URL): ArenaPrediction { - return createWasmPrediction({ - source, - exportName: PREDICT.export, - state: PREDICT_STATE, - context: PREDICT_CONTEXT, - encodeInput - }); -} diff --git a/examples/game/client/src/generated/arena/schema.ts b/examples/game/client/src/generated/arena/schema.ts deleted file mode 100644 index 60cb7d0a..00000000 --- a/examples/game/client/src/generated/arena/schema.ts +++ /dev/null @@ -1,136 +0,0 @@ -// @generated by `distributed game-client` from game surface `arena` (schema v3, protocol v1). -// Replicated kinds: ids, typed fields, wire types, correction defaults. -// Do not edit: change the Rust definitions and regenerate. -import { fieldNumber, fieldString, type CorrectionPolicies, type EntityFields, type WireType } from '@hops-ops/distributed/game'; - -/** Replicated kind ids by name. */ -export const KIND = { - player: 1, - drone: 2, - pillar: 3, -} as const; - -export type KindName = keyof typeof KIND; - -/** `player` (kind 1, priority 2). */ -export interface PlayerFields { - /** `quant [-20, 19.999847] / 18 bits` */ - readonly x: number; - /** `quant [-20, 19.999847] / 18 bits` */ - readonly y: number; - /** `quant [-16, 15.9921875] / 12 bits` */ - readonly vx: number; - /** `quant [-16, 15.9921875] / 12 bits` */ - readonly vy: number; - /** `u8` */ - readonly dash: number; - /** `u8` */ - readonly cd: number; - /** `short_str`, priority 0.2 */ - readonly name: string; -} - -/** True for a `player` entity. */ -export function isPlayer(entity: { readonly kind: number }): boolean { - return entity.kind === 1; -} - -/** Typed fields of a `player` entity (rendered or replica). */ -export function playerFields(entity: { readonly fields: EntityFields }): PlayerFields { - const f = entity.fields; - return { - x: fieldNumber(f, 'x'), - y: fieldNumber(f, 'y'), - vx: fieldNumber(f, 'vx'), - vy: fieldNumber(f, 'vy'), - dash: fieldNumber(f, 'dash'), - cd: fieldNumber(f, 'cd'), - name: fieldString(f, 'name'), - }; -} - -/** `drone` (kind 2, priority 1). */ -export interface DroneFields { - /** `quant [-20, 20] / 14 bits` */ - readonly x: number; - /** `quant [-20, 20] / 14 bits` */ - readonly y: number; - /** `angle / 8 bits` */ - readonly yaw: number; -} - -/** True for a `drone` entity. */ -export function isDrone(entity: { readonly kind: number }): boolean { - return entity.kind === 2; -} - -/** Typed fields of a `drone` entity (rendered or replica). */ -export function droneFields(entity: { readonly fields: EntityFields }): DroneFields { - const f = entity.fields; - return { - x: fieldNumber(f, 'x'), - y: fieldNumber(f, 'y'), - yaw: fieldNumber(f, 'yaw'), - }; -} - -/** `pillar` (kind 3, priority 1). */ -export interface PillarFields { - /** `f32` */ - readonly x: number; - /** `f32` */ - readonly y: number; - /** `f32` */ - readonly r: number; -} - -/** True for a `pillar` entity. */ -export function isPillar(entity: { readonly kind: number }): boolean { - return entity.kind === 3; -} - -/** Typed fields of a `pillar` entity (rendered or replica). */ -export function pillarFields(entity: { readonly fields: EntityFields }): PillarFields { - const f = entity.fields; - return { - x: fieldNumber(f, 'x'), - y: fieldNumber(f, 'y'), - r: fieldNumber(f, 'r'), - }; -} - -/** Wire type of every replicated field, by kind and wire field name. */ -export const FIELD_TYPES = { - player: { - x: { type: 'quant', min: -20, max: 19.999847412109375, bits: 18 }, - y: { type: 'quant', min: -20, max: 19.999847412109375, bits: 18 }, - vx: { type: 'quant', min: -16, max: 15.9921875, bits: 12 }, - vy: { type: 'quant', min: -16, max: 15.9921875, bits: 12 }, - dash: { type: 'u8' }, - cd: { type: 'u8' }, - name: { type: 'short_str' }, - }, - drone: { - x: { type: 'quant', min: -20, max: 20, bits: 14 }, - y: { type: 'quant', min: -20, max: 20, bits: 14 }, - yaw: { type: 'angle', bits: 8 }, - }, - pillar: { - x: { type: 'f32' }, - y: { type: 'f32' }, - r: { type: 'f32' }, - }, -} as const satisfies Record>; - -/** Client correction defaults (`#[correct(..)]`), by kind and wire field name. */ -export const CORRECTIONS: CorrectionPolicies = { - player: { - x: { mode: 'ease', durationMs: 150 }, - y: { mode: 'ease', durationMs: 150 }, - vx: { mode: 'snap' }, - vy: { mode: 'snap' }, - dash: { mode: 'snap' }, - cd: { mode: 'snap' }, - name: { mode: 'replace' }, - }, -}; diff --git a/examples/game/client/src/generated/arena/surface.json b/examples/game/client/src/generated/arena/surface.json deleted file mode 100644 index 14d787ce..00000000 --- a/examples/game/client/src/generated/arena/surface.json +++ /dev/null @@ -1,397 +0,0 @@ -{ - "constants": [ - { - "name": "arena_floor", - "type": { - "name": "ArenaFloor", - "type": "record" - }, - "value": { - "dash_cooldown": 45, - "half": 20.0, - "pillars": [ - { - "r": 2.5, - "x": 0.0, - "y": 0.0 - }, - { - "r": 1.5, - "x": -9.0, - "y": -9.0 - }, - { - "r": 1.5, - "x": 9.0, - "y": -9.0 - }, - { - "r": 1.5, - "x": -9.0, - "y": 9.0 - }, - { - "r": 1.5, - "x": 9.0, - "y": 9.0 - }, - { - "r": 1.0, - "x": 0.0, - "y": -14.0 - }, - { - "r": 1.0, - "x": 0.0, - "y": 14.0 - }, - { - "r": 1.0, - "x": -14.0, - "y": 0.0 - }, - { - "r": 1.0, - "x": 14.0, - "y": 0.0 - } - ], - "player_radius": 0.5 - } - } - ], - "events": [ - { - "id": 1, - "name": "hello", - "payload": { - "fields": [ - { - "name": "entity", - "type": { - "type": "u32" - } - }, - { - "name": "view_radius", - "type": { - "type": "f32" - } - }, - { - "name": "half", - "type": { - "type": "f32" - } - } - ], - "kind": "fields" - } - } - ], - "input": { - "fields": [ - { - "name": "mx", - "range": [ - -127.0, - 127.0 - ], - "type": { - "type": "i8" - } - }, - { - "name": "my", - "range": [ - -127.0, - 127.0 - ], - "type": { - "type": "i8" - } - }, - { - "buttons": [ - "dash" - ], - "name": "buttons", - "type": { - "type": "u8" - } - } - ], - "name": "MoveInput" - }, - "kinds": [ - { - "fields": [ - { - "correction": { - "durationMs": 150, - "mode": "ease" - }, - "name": "x", - "priority": 1.0, - "type": { - "bits": 18, - "max": 19.999847, - "min": -20.0, - "type": "quant" - } - }, - { - "correction": { - "durationMs": 150, - "mode": "ease" - }, - "name": "y", - "priority": 1.0, - "type": { - "bits": 18, - "max": 19.999847, - "min": -20.0, - "type": "quant" - } - }, - { - "correction": { - "mode": "snap" - }, - "name": "vx", - "priority": 1.0, - "type": { - "bits": 12, - "max": 15.9921875, - "min": -16.0, - "type": "quant" - } - }, - { - "correction": { - "mode": "snap" - }, - "name": "vy", - "priority": 1.0, - "type": { - "bits": 12, - "max": 15.9921875, - "min": -16.0, - "type": "quant" - } - }, - { - "correction": { - "mode": "snap" - }, - "name": "dash", - "priority": 1.0, - "type": { - "type": "u8" - } - }, - { - "correction": { - "mode": "snap" - }, - "name": "cd", - "priority": 1.0, - "type": { - "type": "u8" - } - }, - { - "correction": { - "mode": "replace" - }, - "name": "name", - "priority": 0.2, - "type": { - "type": "short_str" - } - } - ], - "id": 1, - "name": "player", - "priority": 2.0 - }, - { - "fields": [ - { - "correction": null, - "name": "x", - "priority": 1.0, - "type": { - "bits": 14, - "max": 20.0, - "min": -20.0, - "type": "quant" - } - }, - { - "correction": null, - "name": "y", - "priority": 1.0, - "type": { - "bits": 14, - "max": 20.0, - "min": -20.0, - "type": "quant" - } - }, - { - "correction": null, - "name": "yaw", - "priority": 1.0, - "type": { - "bits": 8, - "type": "angle" - } - } - ], - "id": 2, - "name": "drone", - "priority": 1.0 - }, - { - "fields": [ - { - "correction": null, - "name": "x", - "priority": 1.0, - "type": { - "type": "f32" - } - }, - { - "correction": null, - "name": "y", - "priority": 1.0, - "type": { - "type": "f32" - } - }, - { - "correction": null, - "name": "r", - "priority": 1.0, - "type": { - "type": "f32" - } - } - ], - "id": 3, - "name": "pillar", - "priority": 1.0 - } - ], - "name": "arena", - "predict": { - "abi": 1, - "export": "arena_step", - "kind": "player", - "package": "game_sim", - "state": "MoveState" - }, - "protocolVersion": 1, - "records": [ - { - "fields": [ - { - "name": "half", - "type": { - "type": "f32" - } - }, - { - "name": "player_radius", - "type": { - "type": "f32" - } - }, - { - "name": "dash_cooldown", - "type": { - "type": "u8" - } - }, - { - "name": "pillars", - "type": { - "item": { - "name": "Pillar", - "type": "record" - }, - "type": "list" - } - } - ], - "name": "ArenaFloor" - }, - { - "fields": [ - { - "name": "x", - "type": { - "type": "f32" - } - }, - { - "name": "y", - "type": { - "type": "f32" - } - }, - { - "name": "vx", - "type": { - "type": "f32" - } - }, - { - "name": "vy", - "type": { - "type": "f32" - } - }, - { - "name": "dash", - "type": { - "type": "u8" - } - }, - { - "name": "cd", - "type": { - "type": "u8" - } - } - ], - "name": "MoveState" - }, - { - "fields": [ - { - "name": "x", - "type": { - "type": "f32" - } - }, - { - "name": "y", - "type": { - "type": "f32" - } - }, - { - "name": "r", - "type": { - "type": "f32" - } - } - ], - "name": "Pillar" - } - ], - "schemaVersion": 3 -} diff --git a/examples/game/client/src/generated/freeze/constants.ts b/examples/game/client/src/generated/freeze/constants.ts index 49ef025b..27149e3e 100644 --- a/examples/game/client/src/generated/freeze/constants.ts +++ b/examples/game/client/src/generated/freeze/constants.ts @@ -1,7 +1,25 @@ // @generated by `distributed game-client` from game surface `freeze` (schema v3, protocol v1). // Constants: static data defined once in Rust (`GameSurface::constant`). // Do not edit: change the Rust definitions and regenerate. -import type { FreezeWorld } from './events.ts'; +import type { ArenaFloor, FreezeWorld } from './events.ts'; + +/** `arena_floor`, as the client decodes it. */ +export const ARENA_FLOOR: ArenaFloor = { + half: 20, + playerRadius: 0.5, + dashCooldown: 45, + pillars: [ + { x: 0, y: 0, r: 2.5 }, + { x: -9, y: -9, r: 1.5 }, + { x: 9, y: -9, r: 1.5 }, + { x: -9, y: 9, r: 1.5 }, + { x: 9, y: 9, r: 1.5 }, + { x: 0, y: -14, r: 1 }, + { x: 0, y: 14, r: 1 }, + { x: -14, y: 0, r: 1 }, + { x: 14, y: 0, r: 1 } + ] +}; /** `freeze_world`, as the client decodes it. */ export const FREEZE_WORLD: FreezeWorld = { diff --git a/examples/game/client/src/generated/freeze/events.ts b/examples/game/client/src/generated/freeze/events.ts index 0e4cebdb..ca32e054 100644 --- a/examples/game/client/src/generated/freeze/events.ts +++ b/examples/game/client/src/generated/freeze/events.ts @@ -3,6 +3,21 @@ // Do not edit: change the Rust definitions and regenerate. import { decodeGameEvent, decodePayload, decodeValue, encodePayload, encodeValue, onGameEvent, type DecodedGameEvent, type GameEventCodecs, type GameEventSource, type PayloadLayout, type WireType } from '@hops-ops/distributed/game'; +/** Record `Pillar` (`#[derive(GameRecord)]`). */ +export interface Pillar { + readonly x: number; + readonly y: number; + readonly r: number; +} + +/** Record `ArenaFloor` (`#[derive(GameRecord)]`). */ +export interface ArenaFloor { + readonly half: number; + readonly playerRadius: number; + readonly dashCooldown: number; + readonly pillars: readonly Pillar[]; +} + /** Record `Portal` (`#[derive(GameRecord)]`). */ export interface Portal { readonly x: number; diff --git a/examples/game/client/src/generated/freeze/surface.json b/examples/game/client/src/generated/freeze/surface.json index 6715a24b..736bd60f 100644 --- a/examples/game/client/src/generated/freeze/surface.json +++ b/examples/game/client/src/generated/freeze/surface.json @@ -3,6 +3,65 @@ "Pack" ], "constants": [ + { + "name": "arena_floor", + "type": { + "name": "ArenaFloor", + "type": "record" + }, + "value": { + "dash_cooldown": 45, + "half": 20.0, + "pillars": [ + { + "r": 2.5, + "x": 0.0, + "y": 0.0 + }, + { + "r": 1.5, + "x": -9.0, + "y": -9.0 + }, + { + "r": 1.5, + "x": 9.0, + "y": -9.0 + }, + { + "r": 1.5, + "x": -9.0, + "y": 9.0 + }, + { + "r": 1.5, + "x": 9.0, + "y": 9.0 + }, + { + "r": 1.0, + "x": 0.0, + "y": -14.0 + }, + { + "r": 1.0, + "x": 0.0, + "y": 14.0 + }, + { + "r": 1.0, + "x": -14.0, + "y": 0.0 + }, + { + "r": 1.0, + "x": 14.0, + "y": 0.0 + } + ], + "player_radius": 0.5 + } + }, { "name": "freeze_world", "type": { @@ -570,6 +629,39 @@ }, "protocolVersion": 1, "records": [ + { + "fields": [ + { + "name": "half", + "type": { + "type": "f32" + } + }, + { + "name": "player_radius", + "type": { + "type": "f32" + } + }, + { + "name": "dash_cooldown", + "type": { + "type": "u8" + } + }, + { + "name": "pillars", + "type": { + "item": { + "name": "Pillar", + "type": "record" + }, + "type": "list" + } + } + ], + "name": "ArenaFloor" + }, { "fields": [ { @@ -718,6 +810,29 @@ ], "name": "Pack" }, + { + "fields": [ + { + "name": "x", + "type": { + "type": "f32" + } + }, + { + "name": "y", + "type": { + "type": "f32" + } + }, + { + "name": "r", + "type": { + "type": "f32" + } + } + ], + "name": "Pillar" + }, { "fields": [ { diff --git a/examples/game/client/src/generated/mobpit/constants.ts b/examples/game/client/src/generated/mobpit/constants.ts index 2b523df3..b1acf0eb 100644 --- a/examples/game/client/src/generated/mobpit/constants.ts +++ b/examples/game/client/src/generated/mobpit/constants.ts @@ -20,6 +20,19 @@ export const PIT_RULES: PitRules = { export const PIT_WORLD: PitWorld = { defaultSeed: 186474221, half: 20, + playerRadius: 0.5, + dashCooldown: 45, + pillars: [ + { x: 0, y: 0, r: 2.5 }, + { x: -9, y: -9, r: 1.5 }, + { x: 9, y: -9, r: 1.5 }, + { x: -9, y: 9, r: 1.5 }, + { x: 9, y: 9, r: 1.5 }, + { x: 0, y: -14, r: 1 }, + { x: 0, y: 14, r: 1 }, + { x: -14, y: 0, r: 1 }, + { x: 14, y: 0, r: 1 } + ], doorHalf: 2.5, roomSize: 40, passage: 2, diff --git a/examples/game/client/src/generated/mobpit/events.ts b/examples/game/client/src/generated/mobpit/events.ts index b9815227..4a305927 100644 --- a/examples/game/client/src/generated/mobpit/events.ts +++ b/examples/game/client/src/generated/mobpit/events.ts @@ -20,6 +20,13 @@ export interface MoveState { readonly cd: number; } +/** Record `Pillar` (`#[derive(GameRecord)]`). */ +export interface Pillar { + readonly x: number; + readonly y: number; + readonly r: number; +} + /** Record `PitRoomContext` (`#[derive(GameRecord)]`). */ export interface PitRoomContext { readonly seed: number; @@ -54,6 +61,9 @@ export interface PitRules { export interface PitWorld { readonly defaultSeed: number; readonly half: number; + readonly playerRadius: number; + readonly dashCooldown: number; + readonly pillars: readonly Pillar[]; readonly doorHalf: number; readonly roomSize: number; readonly passage: number; @@ -95,6 +105,7 @@ export interface HelloEvent { readonly seed: number; readonly restored: boolean; readonly roomKills: number; + readonly viewRadius: number; } const HELLO_LAYOUT: PayloadLayout = [ @@ -107,6 +118,7 @@ const HELLO_LAYOUT: PayloadLayout = [ ['seed', { type: 'u32' }], ['restored', { type: 'bool' }], ['roomKills', { type: 'u32' }], + ['viewRadius', { type: 'f32' }], ]; /** Event `profile` (id 2). */ diff --git a/examples/game/client/src/generated/mobpit/surface.json b/examples/game/client/src/generated/mobpit/surface.json index 4299b39e..8fa40e36 100644 --- a/examples/game/client/src/generated/mobpit/surface.json +++ b/examples/game/client/src/generated/mobpit/surface.json @@ -50,12 +50,61 @@ "forest", "meadow" ], + "dash_cooldown": 45, "default_seed": 186474221, "door_half": 2.5, "half": 20.0, "hysteresis": 0.5, "max_room": 1000000, "passage": 2.0, + "pillars": [ + { + "r": 2.5, + "x": 0.0, + "y": 0.0 + }, + { + "r": 1.5, + "x": -9.0, + "y": -9.0 + }, + { + "r": 1.5, + "x": 9.0, + "y": -9.0 + }, + { + "r": 1.5, + "x": -9.0, + "y": 9.0 + }, + { + "r": 1.5, + "x": 9.0, + "y": 9.0 + }, + { + "r": 1.0, + "x": 0.0, + "y": -14.0 + }, + { + "r": 1.0, + "x": 0.0, + "y": 14.0 + }, + { + "r": 1.0, + "x": -14.0, + "y": 0.0 + }, + { + "r": 1.0, + "x": 14.0, + "y": 0.0 + } + ], + "player_radius": 0.5, "room_size": 40.0 } } @@ -119,6 +168,12 @@ "type": { "type": "u32" } + }, + { + "name": "view_radius", + "type": { + "type": "f32" + } } ], "kind": "fields" @@ -745,6 +800,29 @@ ], "name": "MoveState" }, + { + "fields": [ + { + "name": "x", + "type": { + "type": "f32" + } + }, + { + "name": "y", + "type": { + "type": "f32" + } + }, + { + "name": "r", + "type": { + "type": "f32" + } + } + ], + "name": "Pillar" + }, { "fields": [ { @@ -851,6 +929,28 @@ "type": "f32" } }, + { + "name": "player_radius", + "type": { + "type": "f32" + } + }, + { + "name": "dash_cooldown", + "type": { + "type": "u8" + } + }, + { + "name": "pillars", + "type": { + "item": { + "name": "Pillar", + "type": "record" + }, + "type": "list" + } + }, { "name": "door_half", "type": { diff --git a/examples/game/client/src/generated/ripple/constants.ts b/examples/game/client/src/generated/ripple/constants.ts index 9e1ab7c6..6176587f 100644 --- a/examples/game/client/src/generated/ripple/constants.ts +++ b/examples/game/client/src/generated/ripple/constants.ts @@ -1,7 +1,25 @@ // @generated by `distributed game-client` from game surface `ripple` (schema v3, protocol v1). // Constants: static data defined once in Rust (`GameSurface::constant`). // Do not edit: change the Rust definitions and regenerate. -import type { RippleWorld } from './events.ts'; +import type { ArenaFloor, RippleWorld } from './events.ts'; + +/** `arena_floor`, as the client decodes it. */ +export const ARENA_FLOOR: ArenaFloor = { + half: 20, + playerRadius: 0.5, + dashCooldown: 45, + pillars: [ + { x: 0, y: 0, r: 2.5 }, + { x: -9, y: -9, r: 1.5 }, + { x: 9, y: -9, r: 1.5 }, + { x: -9, y: 9, r: 1.5 }, + { x: 9, y: 9, r: 1.5 }, + { x: 0, y: -14, r: 1 }, + { x: 0, y: 14, r: 1 }, + { x: -14, y: 0, r: 1 }, + { x: 14, y: 0, r: 1 } + ] +}; /** `ripple_world`, as the client decodes it. */ export const RIPPLE_WORLD: RippleWorld = { diff --git a/examples/game/client/src/generated/ripple/events.ts b/examples/game/client/src/generated/ripple/events.ts index 2915a4b9..9ea2702d 100644 --- a/examples/game/client/src/generated/ripple/events.ts +++ b/examples/game/client/src/generated/ripple/events.ts @@ -3,6 +3,21 @@ // Do not edit: change the Rust definitions and regenerate. import { decodeGameEvent, decodePayload, decodeValue, encodePayload, encodeValue, onGameEvent, type DecodedGameEvent, type GameEventCodecs, type GameEventSource, type PayloadLayout, type WireType } from '@hops-ops/distributed/game'; +/** Record `Pillar` (`#[derive(GameRecord)]`). */ +export interface Pillar { + readonly x: number; + readonly y: number; + readonly r: number; +} + +/** Record `ArenaFloor` (`#[derive(GameRecord)]`). */ +export interface ArenaFloor { + readonly half: number; + readonly playerRadius: number; + readonly dashCooldown: number; + readonly pillars: readonly Pillar[]; +} + /** Record `BoroughLayout` (`#[derive(GameRecord)]`). */ export interface BoroughLayout { readonly id: string; diff --git a/examples/game/client/src/generated/ripple/surface.json b/examples/game/client/src/generated/ripple/surface.json index 3b1bae7b..f795c485 100644 --- a/examples/game/client/src/generated/ripple/surface.json +++ b/examples/game/client/src/generated/ripple/surface.json @@ -1,5 +1,64 @@ { "constants": [ + { + "name": "arena_floor", + "type": { + "name": "ArenaFloor", + "type": "record" + }, + "value": { + "dash_cooldown": 45, + "half": 20.0, + "pillars": [ + { + "r": 2.5, + "x": 0.0, + "y": 0.0 + }, + { + "r": 1.5, + "x": -9.0, + "y": -9.0 + }, + { + "r": 1.5, + "x": 9.0, + "y": -9.0 + }, + { + "r": 1.5, + "x": -9.0, + "y": 9.0 + }, + { + "r": 1.5, + "x": 9.0, + "y": 9.0 + }, + { + "r": 1.0, + "x": 0.0, + "y": -14.0 + }, + { + "r": 1.0, + "x": 0.0, + "y": 14.0 + }, + { + "r": 1.0, + "x": -14.0, + "y": 0.0 + }, + { + "r": 1.0, + "x": 14.0, + "y": 0.0 + } + ], + "player_radius": 0.5 + } + }, { "name": "ripple_world", "type": { @@ -450,6 +509,39 @@ }, "protocolVersion": 1, "records": [ + { + "fields": [ + { + "name": "half", + "type": { + "type": "f32" + } + }, + { + "name": "player_radius", + "type": { + "type": "f32" + } + }, + { + "name": "dash_cooldown", + "type": { + "type": "u8" + } + }, + { + "name": "pillars", + "type": { + "item": { + "name": "Pillar", + "type": "record" + }, + "type": "list" + } + } + ], + "name": "ArenaFloor" + }, { "fields": [ { @@ -511,6 +603,29 @@ ], "name": "MoveState" }, + { + "fields": [ + { + "name": "x", + "type": { + "type": "f32" + } + }, + { + "name": "y", + "type": { + "type": "f32" + } + }, + { + "name": "r", + "type": { + "type": "f32" + } + } + ], + "name": "Pillar" + }, { "fields": [ { diff --git a/examples/game/client/src/leaderboard/main.ts b/examples/game/client/src/leaderboard/main.ts index bf8be5d2..bd34cc5a 100644 --- a/examples/game/client/src/leaderboard/main.ts +++ b/examples/game/client/src/leaderboard/main.ts @@ -4,7 +4,6 @@ * change, triggers a rebuild from the cells' event history, and observes * the entrance room (no avatar) for a live mini map. */ -import { ARENA_FLOOR } from '../generated/arena/constants.ts'; import { createMobpitClient, isMob, @@ -12,7 +11,8 @@ import { isPlayer, mobFields, pillarFields, - playerFields + playerFields, + PIT_WORLD } from '../generated/mobpit/index.ts'; import { createBoardWatcher, @@ -26,8 +26,8 @@ import { } from '../mobpit/client.ts'; import { DevApi } from '../shared/dev-api.ts'; -/** The Arena floor, generated from the simulation's constants (`game_sim::arena::arena_floor`). */ -const { half: HALF } = ARENA_FLOOR; +/** A room's floor, generated from the simulation's constants (`game_sim::mobpit::pit_world`). */ +const { half: HALF } = PIT_WORLD; const params = new URLSearchParams(location.search); const zone = params.get('zone') ?? DEFAULT_ZONE; diff --git a/examples/game/client/src/mobpit/main.ts b/examples/game/client/src/mobpit/main.ts index b8e8c8ec..dfe5517e 100644 --- a/examples/game/client/src/mobpit/main.ts +++ b/examples/game/client/src/mobpit/main.ts @@ -15,6 +15,14 @@ * (Space): the grey "saving…" popup turns into "+XP saved ✓" when the kill * settled in your Crawler cell. The debug overlay (O) shows which celld * node and isolate run the room — celld decides, the game never does. + * + * Network panel (the HUD): latency / jitter / loss sliders apply to every + * room on celld (each room object delays its sockets' frames both ways); + * your capsule is predicted (the Rust step as WASM) and reconciled — P + * turns prediction off to feel the raw round trip, G shows the server's + * authoritative position as a wireframe ghost, and the readout counts + * pending inputs and corrections. The ring on the floor is your interest + * radius: mobs and players beyond it are not replicated to you. */ import type { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial'; import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture'; @@ -25,7 +33,6 @@ import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder'; import { TransformNode } from '@babylonjs/core/Meshes/transformNode'; import { InputPacer, type RenderedEntity } from '@hops-ops/distributed/game'; -import { ARENA_FLOOR } from '../generated/arena/constants.ts'; import { BUTTON, PIT_RULES, @@ -42,7 +49,7 @@ import { import { DevApi } from '../shared/dev-api.ts'; import { Hud } from '../shared/hud.ts'; import { cameraRelative, InputSampler, toAxis } from '../shared/input.ts'; -import { createStage, material } from '../shared/scene.ts'; +import { createRing, createStage, material } from '../shared/scene.ts'; import { createBoardWatcher, createMobPitClient, @@ -57,8 +64,8 @@ import { type RoomStatus } from './client.ts'; -/** The Arena floor, generated from the simulation's constants (`game_sim::arena::arena_floor`). */ -const { half: HALF, playerRadius: PLAYER_RADIUS, pillars: PILLARS } = ARENA_FLOOR; +/** The rooms' floor, generated from the simulation's constants (`game_sim::mobpit::pit_world`). */ +const { half: HALF, playerRadius: PLAYER_RADIUS, pillars: PILLARS } = PIT_WORLD; /** Rooms drawn around yours (2 → 5×5). */ const ROOM_VIEW = 2; /** Wall thickness and height (m), drawn just outside each room's floor. */ @@ -91,12 +98,21 @@ const { scene } = stage; const hud = new Hud({ title: 'Mob Pit', - hint: 'WASD move · Space attack · E dash · walk through a doorway to the next room · O debug overlay. Rooms run on celld; kills are durable facts.', + hint: 'WASD move · Space attack · E dash · walk through a doorway to the next room · O debug overlay · P prediction · G server ghost. Rooms run on celld; kills are durable facts.', client, devApi: api }); let debug = params.get('debug') !== '0'; hud.toggle('Debug overlay (celld node per room)', debug, (on) => (debug = on), 'o'); +hud.toggle('Client prediction', true, (on) => client.setPredictionEnabled(on), 'p'); +let ghostOn = true; +hud.toggle('Server ghost', true, (on) => (ghostOn = on), 'g'); +let corrections = 0; +let lastCorrection = 0; +client.on('correction', (c) => { + corrections += 1; + lastCorrection = Math.hypot(c.errors.x ?? 0, c.errors.y ?? 0); +}); // ---- side panels: XP bar, durable facts graph, mini leaderboard ---- const xpPanel = div('pit-xp', document.body); @@ -237,8 +253,10 @@ const mats = { hpBack: material(scene, 'hp-back', '#1b1b1b'), hpFill: material(scene, 'hp-fill', '#e04848'), tele: material(scene, 'tele', '#ff3b3b', 0.25), - teleFill: material(scene, 'tele-fill', '#ff3b3b', 0.45) + teleFill: material(scene, 'tele-fill', '#ff3b3b', 0.45), + ghost: material(scene, 'ghost', '#ffffff', 0.25) }; +mats.ghost.wireframe = true; for (const m of [mats.swing, mats.tele, mats.teleFill, mats.hpFill]) m.emissiveColor = m.diffuseColor.clone(); mats.blade.emissiveColor = new Color3(0.6, 0.6, 0.7); const tierMats = new Map(); @@ -416,6 +434,16 @@ function showRoom(zone: string): void { } } +// ---- network panel: server ghost and interest ring ---- +const ghost = MeshBuilder.CreateCapsule('ghost', { radius: PLAYER_RADIUS, height: 1.8 }, scene); +ghost.material = mats.ghost; +ghost.isPickable = false; +ghost.setEnabled(false); +/** Unit ring scaled to the room's interest radius (`hello.viewRadius`). */ +const interestRing = createRing(scene, 1, '#6cc4ff'); +interestRing.isPickable = false; +interestRing.setEnabled(false); + type PlayerVisual = { kind: 'player'; body: Mesh; blade: Mesh; arc: Mesh; nose: Mesh }; type MobVisual = { kind: 'mob'; body: Mesh; hpBack: Mesh; hpFill: Mesh; ring: Mesh; fill: Mesh; tier: number }; type Visual = PlayerVisual | MobVisual; @@ -679,6 +707,22 @@ stage.onFrame((now, dtMs) => { } if (me !== undefined) stage.follow(me.at.x, me.at.z, dtMs); + // The server's own copy of you (authoritative, behind by the round + // trip): a wireframe ghost in the room that owns you. + const authoritative = client.predictor?.authoritative(); + let ghostGap: number | undefined; + if (authoritative !== undefined && authority !== undefined) { + const g = playerFields({ fields: authoritative }); + ghost.position.copyFrom(placeIn(authority.zoneId, g.x, g.y, 0.9)); + if (me !== undefined) ghostGap = Math.hypot(ghost.position.x - me.at.x, ghost.position.z - me.at.z); + } + ghost.setEnabled(ghostOn && authoritative !== undefined && !(stage.view() === 'first' && (ghostGap ?? 0) < 0.8)); + interestRing.setEnabled(me !== undefined && hello !== undefined); + if (me !== undefined && hello !== undefined) { + interestRing.position.set(me.at.x, 0, me.at.z); + interestRing.scaling.setAll(hello.viewRadius); + } + // ---- HUD ---- hud.frame({ entities: rendered.length }); const pending = stats?.pending ?? 0; @@ -691,6 +735,13 @@ stage.onFrame((now, dtMs) => { hud.row('My unsaved kills', String(pit.unsaved.size)); hud.row('Kills in this room', String(stats?.kills ?? 0)); hud.row('Room tick interval', `${tickMs.toFixed(1)} ms avg · ${tickMax.toFixed(1)} max`); + const predictor = client.predictor; + hud.row('Prediction', client.predictionEnabled ? 'on (pit_step as WASM)' : 'off (raw round trip)'); + hud.row('Pending inputs', String(predictor?.pending.length ?? 0)); + hud.row('Ack seq', String(predictor?.lastAck ?? 0)); + hud.row('Corrections', `${corrections} (last ${lastCorrection.toFixed(3)} m)`); + hud.row('Ghost gap', ghostGap === undefined ? '—' : `${ghostGap.toFixed(2)} m`); + hud.row('Interest', hello === undefined ? '—' : `${hello.viewRadius} m ring · ${rendered.filter((e) => isMob(e) && !e.stale).length} mobs replicated`); const profile = pit.profile(); if (profile !== undefined) { const span = Math.max(1, profile.nextLevelXp - profile.levelXp); diff --git a/examples/game/client/src/ripple/main.ts b/examples/game/client/src/ripple/main.ts index 39c7e09c..9ffa4632 100644 --- a/examples/game/client/src/ripple/main.ts +++ b/examples/game/client/src/ripple/main.ts @@ -17,7 +17,7 @@ import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder'; import { Scene } from '@babylonjs/core/scene'; import { InputPacer, type RenderedEntity } from '@hops-ops/distributed/game'; -import { ARENA_FLOOR } from '../generated/arena/constants.ts'; +import { ARENA_FLOOR } from '../generated/ripple/constants.ts'; import { BITS, isPlayer, playerFields } from '../generated/ripple/index.ts'; import { DevApi } from '../shared/dev-api.ts'; import { Hud } from '../shared/hud.ts'; diff --git a/examples/game/client/src/shared/hud.ts b/examples/game/client/src/shared/hud.ts index 50cd1058..b099c8b7 100644 --- a/examples/game/client/src/shared/hud.ts +++ b/examples/game/client/src/shared/hud.ts @@ -4,7 +4,7 @@ * toggles and the server-side NetSim controls (`/dev/netsim`). * * ```ts - * const hud = new Hud({ title: 'Arena', client, devApi }); + * const hud = new Hud({ title: 'Mob Pit', client, devApi }); * hud.toggle('Prediction', true, (on) => (predict = on)); * stage.onFrame(() => hud.frame({ entities: rendered.length })); * hud.row('Pending inputs', String(n)); diff --git a/examples/game/client/src/shared/latest.ts b/examples/game/client/src/shared/latest.ts index 504402ed..7147cc63 100644 --- a/examples/game/client/src/shared/latest.ts +++ b/examples/game/client/src/shared/latest.ts @@ -3,7 +3,7 @@ * e.g. a zone's `hello` (own entity id) that arrives right after joining: * * ```ts - * const hello = latest((h) => typed.on('hello', h)); + * const hello = latest((h) => typed.on('hello', h)); * ``` */ export type Latest = Readonly<{ diff --git a/examples/game/client/tests/arena-headless.test.mjs b/examples/game/client/tests/arena-headless.test.mjs deleted file mode 100644 index 11fe2657..00000000 --- a/examples/game/client/tests/arena-headless.test.mjs +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Headless Arena client (Node, no browser): dev ticket → join → move with - * prediction (the Rust step compiled to WebAssembly, loaded by the - * generated client); the server agrees with the predicted state bit for - * bit; NetSim control changes the measured RTT. Starts the real - * `game-demos arena` binary. - */ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { createArenaClient } from '../src/arena/client.ts'; -import { DevApi } from '../src/shared/dev-api.ts'; -import { memoryStorage, sleep, startDemo, until } from './helpers/demo-server.mjs'; - -/** The predicted `player` fields (the Rust `MoveState`). */ -const stateFromFields = (f) => ({ x: f.x, y: f.y, vx: f.vx, vy: f.vy, dash: f.dash, cd: f.cd }); - -test('headless client joins the arena and moves with exact prediction', { timeout: 600_000 }, async () => { - const server = await startDemo('arena'); - const clients = []; - try { - const api = new DevApi({ base: server.httpUrl, name: () => 'Headless', storage: memoryStorage() }); - const arena = createArenaClient({ api, base: server.httpUrl }); - clients.push(arena.client); - const { client } = arena; - const welcome = await client.connect('arena-1'); - assert.equal(welcome.tickRate, 30); - assert.deepEqual(welcome.schema.kinds.map((k) => k.name), ['player', 'drone', 'pillar']); - const hello = await arena.ready; - assert.equal(hello.viewRadius, 14); - assert.ok(api.last.principal.startsWith('guest-'), 'server-generated principal'); - await until(() => client.predictor.active, 5_000, 'own entity bound'); - assert.ok(arena.prediction?.loaded(), 'the compiled step is loaded'); - const start = stateFromFields(client.predictor.predicted()); - - // 30 command frames forward + right at the server's tick rate. - const input = { mx: 90, my: 90, buttons: 0 }; - client.sendInput(input); - const first = stateFromFields(client.predictor.predicted()); - assert.ok(first.vx > 0 && first.vy > 0, 'prediction applies the step immediately'); - for (let i = 1; i < 30; i += 1) { - await sleep(1000 / 30); - client.sendInput(input); - } - // What the client showed after the last input: the last rebase plus a - // replay of every then-unacknowledged input through the WASM step. - const expected = stateFromFields(client.predictor.predicted()); - assert.ok(arena.prediction.steps() >= 30, 'every input ran through the WASM step'); - await until(() => client.predictor.lastAck >= 30 && client.predictor.pending.length === 0, 5_000, 'acks'); - const server_ = stateFromFields(client.authority.replica.get(hello.entity).fields); - assert.deepEqual(server_, expected, 'authoritative state equals the prediction bit for bit'); - assert.ok(Math.hypot(server_.x - start.x, server_.y - start.y) > 3, 'moved'); - - // Interpolated world: name label, drones and global pillars are visible. - await sleep(150); - const rendered = client.render(); - const me = rendered.find((e) => e.id === hello.entity); - assert.equal(me.fields.name, 'Headless'); - assert.ok(me.predicted); - assert.equal(rendered.filter((e) => e.kindName === 'pillar').length, 9); - assert.ok(rendered.some((e) => e.kindName === 'drone')); - const stats = client.authority.connection.stats; - assert.ok(stats.deltasIn > 10 && stats.deltaBytesIn > 0); - - // Server-side NetSim raises the RTT the clock measures. - const connection = client.authority.connection; - await until(() => connection.clock.synced, 5_000, 'clock'); - const base = connection.clock.rtt; - await api.setNetSim({ latencyMs: 60, jitterMs: 0, loss: 0 }); - // Wait for exactly what is asserted: the baseline RTT varies by machine. - await until(() => connection.clock.samples.at(-1)?.rtt > base + 100, 10_000, 'rtt with netsim'); - await api.setNetSim({ latencyMs: 0, jitterMs: 0, loss: 0 }); - assert.deepEqual(await api.getNetSim(), { latencyMs: 0, jitterMs: 0, loss: 0 }); - - // A second guest: players are global, so it sees the first one at any - // distance (interpolated, not predicted, with its display name), while - // drones stay culled by the view radius. - const other = createArenaClient({ - api: new DevApi({ base: server.httpUrl, name: () => 'Other', storage: memoryStorage() }), - base: server.httpUrl - }); - clients.push(other.client); - await other.client.connect('arena-1'); - const otherHello = await other.ready; - await until(() => other.client.predictor.active, 5_000, 'second entity bound'); - const sees = () => - other.client.render().some((e) => e.id === hello.entity && !e.predicted && e.fields.name === 'Headless'); - await until(sees, 5_000, 'remote player visible regardless of distance'); - await until( - () => { - const at = stateFromFields(other.client.predictor.predicted()); - const drones = other.client.render().filter((e) => e.kindName === 'drone' && !e.stale); - return ( - drones.length > 0 && - drones.every((d) => Math.hypot(d.fields.x - at.x, d.fields.y - at.y) <= otherHello.viewRadius * 1.25 + 1) - ); - }, - 5_000, - 'only drones near the view radius are replicated' - ); - } finally { - for (const c of clients) c.close(); - await server.stop(); - } -}); diff --git a/examples/game/client/tests/mobpit-network-headless.test.mjs b/examples/game/client/tests/mobpit-network-headless.test.mjs new file mode 100644 index 00000000..ffe98ac4 --- /dev/null +++ b/examples/game/client/tests/mobpit-network-headless.test.mjs @@ -0,0 +1,94 @@ +/** + * Headless Mob Pit network panel (Node): the rooms on their own `celld + * dev`, the services binary, a scripted player. + * + * Prediction: the own capsule runs `pit_step` compiled to WebAssembly and + * the room on celld agrees with it bit for bit. Network simulation: the + * services' `/dev/netsim` writes the rooms' dev settings and every room + * delays its sockets both ways (the measured RTT rises, the room's debug + * info reports it, and it drops again when switched off). Interest: the + * room replicates only what is inside the `viewRadius` ring the page draws. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { isMob, playerFields } from '../src/generated/mobpit/index.ts'; +import { createMobPitClient, MobPitApi } from '../src/mobpit/client.ts'; +import { DevApi } from '../src/shared/dev-api.ts'; +import { eventually, memoryStorage, sleep, until } from './helpers/demo-server.mjs'; +import { startPit } from './helpers/mobpit.mjs'; + +/** The predicted `player` fields (the Rust `MoveState`). */ +const stateFromFields = (f) => ({ x: f.x, y: f.y, vx: f.vx, vy: f.vy, dash: f.dash, cd: f.cd }); + +test('Mob Pit network panel: exact prediction, NetSim on celld rooms, interest ring', { timeout: 600_000 }, async () => { + const server = await startPit('mobpit'); + const clients = []; + try { + const api = new DevApi({ base: server.httpUrl, name: () => 'Netter', storage: memoryStorage() }); + const pitApi = new MobPitApi(server.httpUrl, server.roomsUrl); + const pit = createMobPitClient({ api, roomsBase: server.roomsUrl }); + clients.push(pit.client); + const { client } = pit; + await client.connect('pit.0.0'); + const hello = await pit.ready; + assert.equal(hello.viewRadius, 24, 'the interest ring the page draws'); + await until(() => client.predictor?.active, 5_000, 'own entity bound'); + + // Prediction: what the client showed equals the room's state bit for bit. + const input = { mx: 90, my: -60, buttons: 0 }; + for (let i = 0; i < 30; i += 1) { + client.sendInput(input); + await sleep(1000 / 30); + } + const expected = stateFromFields(client.predictor.predicted()); + await until(() => client.predictor.pending.length === 0, 5_000, 'acks'); + const authoritative = stateFromFields(client.authority.replica.get(hello.entity).fields); + assert.deepEqual(authoritative, expected, 'the room agrees with the WASM prediction'); + assert.deepEqual(stateFromFields(client.predictor.authoritative()), authoritative, 'the ghost is the replica'); + + // NetSim: the rooms' setting raises the RTT through celld. + const connection = client.authority.connection; + await until(() => connection.clock.synced, 5_000, 'clock'); + const base = connection.clock.rtt; + assert.deepEqual(await api.getNetSim(), { latencyMs: 0, jitterMs: 0, loss: 0 }); + assert.deepEqual(await api.setNetSim({ latencyMs: 80, jitterMs: 0, loss: 0 }), { latencyMs: 80, jitterMs: 0, loss: 0 }); + await until(() => connection.clock.samples.at(-1)?.rtt > base + 120, 10_000, 'rtt with netsim'); + const info = await eventually(async () => { + const i = await pitApi.roomInfo('pit.0.0'); + return i.netsim?.latencyMs === 80 && i; + }, 5_000, 'room reports the simulation'); + console.error(`rtt ${base.toFixed(1)} ms → ${connection.clock.samples.at(-1).rtt.toFixed(1)} ms with 80 ms one way (room ${info.zone})`); + // Inputs still reach the room in order under the simulation. + for (let i = 0; i < 15; i += 1) { + client.sendInput({ mx: -90, my: 60, buttons: 0 }); + await sleep(1000 / 30); + } + const late = stateFromFields(client.predictor.predicted()); + await until(() => client.predictor.pending.length === 0, 5_000, 'acks under latency'); + assert.deepEqual(stateFromFields(client.authority.replica.get(hello.entity).fields), late, 'exact under latency too'); + await api.setNetSim({ latencyMs: 0, jitterMs: 0, loss: 0 }); + await until(() => connection.clock.samples.at(-1)?.rtt < base + 60, 10_000, 'rtt back down'); + + // Interest: walk into a corner; every replicated mob is near the ring. + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + // Along +x first, then +y: beside the pillars, into the corner. + const me = playerFields(pit.me()); + if (me.x > 17 && me.y > 17) break; + client.sendInput(me.x <= 17 ? { mx: 127, my: 0, buttons: 0 } : { mx: 0, my: 127, buttons: 0 }); + await sleep(1000 / 30); + } + await sleep(500); + const me = playerFields(pit.me()); + const mobs = client.render().filter((e) => isMob(e) && !e.stale && e.zoneId === 'pit.0.0'); + const layout = pit.pures.pitRoom({ seed: hello.seed, x: 0, y: 0 }); + assert.ok(mobs.length < layout.mobs.length, `far mobs culled (${mobs.length} of ${layout.mobs.length} replicated)`); + for (const m of mobs) { + assert.ok(Math.hypot(m.fields.x - me.x, m.fields.y - me.y) <= hello.viewRadius * 1.25 + 1, 'replicated mobs are inside the ring'); + } + } finally { + for (const c of clients) c.close(); + await server.stop(); + } +}); diff --git a/examples/game/client/tests/prediction-wasm.test.mjs b/examples/game/client/tests/prediction-wasm.test.mjs index faa17c48..ca1afe21 100644 --- a/examples/game/client/tests/prediction-wasm.test.mjs +++ b/examples/game/client/tests/prediction-wasm.test.mjs @@ -59,7 +59,7 @@ const surfaces = readdirSync(GENERATED) }); test('every predicting demo has a compiled step and a native trace', () => { - assert.deepEqual(surfaces.map((s) => s.name).sort(), ['arena', 'freeze', 'grid', 'mobpit', 'ripple']); + assert.deepEqual(surfaces.map((s) => s.name).sort(), ['freeze', 'grid', 'mobpit', 'ripple']); assert.equal(traces.abi, 1); for (const { name, predict } of surfaces) { assert.ok(existsSync(new URL(`${name}/wasm/${name}.wasm`, GENERATED)), `${name}: run \`run.sh gen\``); @@ -209,7 +209,7 @@ test('cost: generating a grid cell and sampling a point in WASM', async () => { test('cost: one predicted step and a reconciliation replaying 10 inputs at 30 Hz', async () => { const report = {}; - for (const name of ['arena', 'grid']) { + for (const name of ['mobpit', 'grid']) { const generated = await import(`../src/generated/${name}/index.ts`); const prediction = generated[`create${name[0].toUpperCase()}${name.slice(1)}Prediction`](); await prediction.ready; diff --git a/examples/game/client/vite.config.ts b/examples/game/client/vite.config.ts index 384457ee..43ae0925 100644 --- a/examples/game/client/vite.config.ts +++ b/examples/game/client/vite.config.ts @@ -7,7 +7,7 @@ import { resolve } from 'node:path'; import { defineConfig } from 'vite'; -const DEMOS = ['arena', 'mobpit', 'leaderboard', 'gallery', 'freeze', 'grid', 'ripple']; +const DEMOS = ['mobpit', 'leaderboard', 'gallery', 'freeze', 'grid', 'ripple']; const server = process.env.GAME_DEMOS_SERVER ?? 'http://127.0.0.1:2567'; const rooms = process.env.GAME_DEMOS_ROOMS ?? 'http://127.0.0.1:2570'; diff --git a/examples/game/run.sh b/examples/game/run.sh index 0fcd48b5..77cd6029 100755 --- a/examples/game/run.sh +++ b/examples/game/run.sh @@ -1,8 +1,6 @@ #!/usr/bin/env bash # distributed_game demos — one command per demo (dev only). # -# ./run.sh arena build what is needed, start the arena server -# (dev routes on) + Vite, print the URL # ./run.sh mobpit Mob Pit: its rooms run on celld (`celld dev`, the # cells worker hosts one Durable Object per room), # the services (tickets, read models, boards) and @@ -65,7 +63,7 @@ SVELTEKIT="$HERE/sveltekit" BIN="$SERVER/target/debug/game-demos" SERVICE_BIN="$SERVER/target/debug/mobpit-service" CELLS="$HERE/cells" -DEMOS=(arena mobpit leaderboard gallery freeze grid ripple) +DEMOS=(mobpit leaderboard gallery freeze grid ripple) # Single-process demos whose durable state lives in celld cells (the # multi-host ones always run on celld too: zone leases and hibernation). CELLD_DEMOS=(mobpit leaderboard freeze) @@ -74,7 +72,7 @@ export MOBPIT_DATA_DIR="${MOBPIT_DATA_DIR:-$HERE/.data/mobpit}" CLUSTER_DEMOS=(grid ripple) # Game surfaces with a generated client (`game_demos::::game_surface`); # the leaderboard page uses Mob Pit's. -SURFACES=(arena mobpit gallery freeze grid ripple) +SURFACES=(mobpit gallery freeze grid ripple) CLI="$REPO/target/debug/distributed" log() { printf '\033[1;36m[demos]\033[0m %s\n' "$*"; } diff --git a/examples/game/server/rooms/src/zone.rs b/examples/game/server/rooms/src/zone.rs index 51ba98e0..de18f9e7 100644 --- a/examples/game/server/rooms/src/zone.rs +++ b/examples/game/server/rooms/src/zone.rs @@ -261,6 +261,8 @@ pub enum PitEvent { restored: bool, /// Mobs killed in this room so far. room_kills: u32, + /// Interest radius (m): what the room replicates around the player. + view_radius: f32, }, /// Durable progress. #[game_event(id = 2)] @@ -362,7 +364,10 @@ const SWING_FRAMES: u8 = 6; /// Player max HP. pub const PLAYER_HP: i32 = 100; const MAX_CREDITS: u8 = 4; -const VIEW_RADIUS: f32 = 40.0; +/// Interest radius (m): mobs and players farther away are not replicated +/// (the page draws it as the interest ring). From any spawn point every mob +/// home is inside it; walk to a corner and the far side drops out. +pub const VIEW_RADIUS: f32 = 24.0; const AGGRO: f32 = 7.0; const LEASH: f32 = 15.0; const WINDUP_TICKS: u16 = 24; @@ -1105,6 +1110,7 @@ impl MobPit { seed: self.room.seed, restored: self.restored, room_kills: self.kills, + view_radius: VIEW_RADIUS, }, ); } diff --git a/examples/game/server/sim/src/mobpit.rs b/examples/game/server/sim/src/mobpit.rs index a1fa4596..c782584a 100644 --- a/examples/game/server/sim/src/mobpit.rs +++ b/examples/game/server/sim/src/mobpit.rs @@ -245,6 +245,12 @@ pub struct PitWorld { pub default_seed: u32, /// Floor half extent (m). pub half: f32, + /// Player capsule radius (m). + pub player_radius: f32, + /// Dash cooldown in command frames (the HUD meter). + pub dash_cooldown: u8, + /// The pillars every room has (room-local). + pub pillars: Vec, /// Doorway half width (m). pub door_half: f32, /// Room edge length (m): room `(x, y)` is centred at world `(x, y) × room_size`. @@ -264,6 +270,9 @@ pub fn pit_world() -> PitWorld { PitWorld { default_seed: DEFAULT_SEED, half: arena::HALF, + player_radius: arena::PLAYER_RADIUS, + dash_cooldown: arena::DASH_COOLDOWN, + pillars: arena::arena_floor().pillars, door_half: DOOR_HALF, room_size: ROOM_SIZE, passage: PASSAGE, diff --git a/examples/game/server/src/arena/mod.rs b/examples/game/server/src/arena/mod.rs deleted file mode 100644 index 8ffe5e1c..00000000 --- a/examples/game/server/src/arena/mod.rs +++ /dev/null @@ -1,464 +0,0 @@ -//! **Arena** — the baseline netcode demo. -//! -//! Mechanics on display: fixed 30 Hz tick / 20 Hz patches, binary deltas, -//! interest management (14 m view radius on a 40×40 m floor: far drones are -//! culled at the ring's edge; players are global so you always see everyone -//! in the room), client prediction + reconciliation of the -//! own capsule ([`movement`], the `game_sim` step the browser runs as -//! WebAssembly) and -//! interpolation of everything else (players, ~30 wandering drones). -//! -//! Entities: -//! -//! | kind | ids | fields | notes | -//! |---|---|---|---| -//! | 1 `player` | 1000 + player | x y vx vy dash cd name | global; full [`MoveState`] so clients can rebase | -//! | 2 `drone` | 1..=30 | x y yaw | wander between random targets | -//! | 3 `pillar` | 100 + i | x y r | global (visible everywhere) | -//! -//! Kinds are `#[derive(Replicated)]` views ([`PlayerView`], [`DroneView`], -//! [`PillarView`]); [`ArenaEvent::Hello`] tells a joining player its entity -//! and the view radius. Inputs are 3 bytes ([`MoveInput`], a -//! `#[derive(GameInput)]`); each input is one command frame. The browser -//! client is generated from [`game_surface`] (`run.sh gen`). A player earns one frame credit per tick (max -//! [`MAX_CREDITS`], `distributed_game::InputCredits`), so a client cannot -//! move faster than real time by sending more inputs, but jitter bursts are -//! absorbed. - -/// The shared movement step and its `#[predict]` declaration. -pub use game_sim::arena as movement; - -use std::collections::BTreeMap; - -use distributed_game::net::{GameServer, ServerConfig, ZoneFactory}; -use distributed_game::{ - schema, Ctx, Entity, EntityId, EntitySchema, GameEvent, GameInput, GameSurface, InputCredits, - LeaveReason, PlayerId, Replicated, View, WorldView, Zone, -}; - -use crate::common::{Demo, Guests}; -pub use movement::{MoveInput, MoveState}; - -/// A player's capsule: the full [`MoveState`] (so the client can rebase its -/// prediction) plus the display name. Global: everyone in the room is seen. -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "player", id = 1, priority = 2.0, global)] -pub struct PlayerView { - /// Position x. - #[field_type(movement::POS)] - #[correct(ease = 150)] - pub x: f32, - /// Position y. - #[field_type(movement::POS)] - #[correct(ease = 150)] - pub y: f32, - /// Velocity x. - #[field_type(movement::VEL)] - #[correct(snap)] - pub vx: f32, - /// Velocity y. - #[field_type(movement::VEL)] - #[correct(snap)] - pub vy: f32, - /// Remaining dash frames. - #[correct(snap)] - pub dash: u8, - /// Remaining dash cooldown frames. - #[correct(snap)] - pub cd: u8, - /// Display name. - #[priority(0.2)] - #[correct(replace)] - pub name: String, -} - -impl PlayerView { - /// The view of a capsule. - pub fn of(state: &MoveState, name: &str) -> Self { - Self { - x: state.x, - y: state.y, - vx: state.vx, - vy: state.vy, - dash: state.dash, - cd: state.cd, - name: name.to_string(), - } - } -} - -/// A wandering drone (culled by the view radius). -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "drone", id = 2)] -pub struct DroneView { - /// Position x. - #[quant(min = -movement::HALF, max = movement::HALF, bits = 14)] - pub x: f32, - /// Position y. - #[quant(min = -movement::HALF, max = movement::HALF, bits = 14)] - pub y: f32, - /// Heading. - #[angle(bits = 8)] - pub yaw: f32, -} - -/// A static pillar of the shared floor ([`movement::PILLARS`]). -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Pillar { - /// Entity id (100 + index). - pub id: EntityId, - /// Centre x. - pub x: f32, - /// Centre y. - pub y: f32, - /// Radius. - pub r: f32, -} - -impl Pillar { - /// Every pillar of the floor. - pub fn all() -> Vec { - movement::PILLARS - .iter() - .enumerate() - .map(|(i, &(x, y, r))| Pillar { - id: EntityId(100 + i as u32), - x, - y, - r, - }) - .collect() - } -} - -impl Entity for Pillar { - fn entity_id(&self) -> EntityId { - self.id - } -} - -/// A static pillar (visible everywhere), a straight projection of [`Pillar`]. -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "pillar", id = 3, global, of = Pillar)] -pub struct PillarView { - /// Centre x. - #[from] - pub x: f32, - /// Centre y. - #[from] - pub y: f32, - /// Radius. - #[from] - pub r: f32, -} - -/// The Arena's events. -#[derive(Debug, Clone, PartialEq, GameEvent)] -pub enum ArenaEvent { - /// To a joining player: its entity, the interest radius and floor size. - #[game_event(id = 1)] - Hello { - /// Own entity id. - entity: u32, - /// Interest radius (m). - view_radius: f32, - /// Floor half extent (m). - half: f32, - }, -} - -/// The Arena's client surface (`distributed game-manifest --entrypoint -/// game_demos::arena::game_surface`). -pub fn game_surface() -> GameSurface { - GameSurface::for_zone::("arena") - .events::() - .predict::() - .constant("arena_floor", &movement::arena_floor()) -} - -/// Interest radius (m). -pub const VIEW_RADIUS: f32 = 14.0; -/// Wandering drones. -pub const DRONES: u32 = 30; -/// Most command frames a player may bank (absorbs input jitter). -pub const MAX_CREDITS: u8 = 4; -const DRONE_SPEED: f32 = 2.5; - -struct Player { - entity: u32, - name: String, - state: MoveState, -} - -struct Drone { - id: u32, - x: f32, - y: f32, - tx: f32, - ty: f32, - yaw: f32, -} - -/// Zone construction parameters. -#[derive(Clone, Default)] -pub struct ArenaConfig { - /// Display names registered by the dev ticket endpoint. - pub guests: Guests, -} - -impl Arena { - /// Tell `player`'s client session its entity and set its view. - fn greet(&self, ctx: &mut Ctx<'_, Self>, player: PlayerId) { - let Some(p) = self.players.get(&player) else { - return; - }; - ctx.set_view(player, View::new(p.state.x, p.state.y, VIEW_RADIUS)); - ctx.send( - player, - &ArenaEvent::Hello { - entity: p.entity, - view_radius: VIEW_RADIUS, - half: movement::HALF, - }, - ); - } -} - -/// The Arena zone. -pub struct Arena { - guests: Guests, - players: BTreeMap, - credits: InputCredits, - drones: Vec, -} - -/// Spawn points on a ring between the pillars. -fn spawn_point(player: PlayerId) -> (f32, f32) { - const SPOTS: [(f32, f32); 8] = [ - (-5.0, -5.0), - (5.0, -5.0), - (5.0, 5.0), - (-5.0, 5.0), - (0.0, -7.0), - (7.0, 0.0), - (0.0, 7.0), - (-7.0, 0.0), - ]; - SPOTS[player.0 as usize % SPOTS.len()] -} - -fn free_point(ctx: &mut Ctx<'_, Arena>) -> (f32, f32) { - loop { - let x = (ctx.rng().next_f32() * 2.0 - 1.0) * (movement::HALF - 1.0); - let y = (ctx.rng().next_f32() * 2.0 - 1.0) * (movement::HALF - 1.0); - let clear = movement::PILLARS - .iter() - .all(|(px, py, pr)| (x - px).powi(2) + (y - py).powi(2) > (pr + 1.0).powi(2)); - if clear { - return (x, y); - } - } -} - -impl Zone for Arena { - type Config = ArenaConfig; - type Input = MoveInput; - type Timer = (); - type Message = distributed_game::NoMessages; - - fn schema() -> EntitySchema { - schema![PlayerView, DroneView, PillarView] - } - - fn on_create(ctx: &mut Ctx<'_, Self>, config: ArenaConfig) -> Self { - let mut arena = Self { - guests: config.guests, - players: BTreeMap::new(), - credits: InputCredits::new(u32::from(MAX_CREDITS)), - drones: Vec::new(), - }; - for id in 1..=DRONES { - let (x, y) = free_point(ctx); - let (tx, ty) = free_point(ctx); - arena.drones.push(Drone { - id, - x, - y, - tx, - ty, - yaw: 0.0, - }); - } - arena - } - - fn on_join(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId) { - let principal = ctx - .player(player) - .map(|p| p.principal.clone()) - .unwrap_or_default(); - let name = self.guests.name(&principal).unwrap_or(principal); - let (x, y) = spawn_point(player); - let entity = 1000 + player.0; - self.players.insert( - player, - Player { - entity, - name, - state: MoveState { - x, - y, - ..MoveState::default() - }, - }, - ); - self.greet(ctx, player); - } - - /// A reloaded page (fresh ticket for a slot it still holds): keep the - /// body where it is, greet the new client session. - fn on_rejoin(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId) { - if self.players.contains_key(&player) { - self.greet(ctx, player); - } else { - self.on_join(ctx, player); - } - } - - fn on_leave(&mut self, _ctx: &mut Ctx<'_, Self>, player: PlayerId, _reason: LeaveReason) { - self.players.remove(&player); - self.credits.remove(player); - } - - fn decode_input(&self, _player: PlayerId, payload: &[u8]) -> Option { - MoveInput::from_payload(payload) - } - - fn on_tick(&mut self, ctx: &mut Ctx<'_, Self>) { - for (id, player) in &mut self.players { - for input in self.credits.take(ctx, *id) { - // The declared predicted step: what the client runs as WASM. - player.state = movement::arena_step(&player.state, &input.input, &(), movement::DT); - } - ctx.set_view(*id, View::new(player.state.x, player.state.y, VIEW_RADIUS)); - } - let dt = ctx.dt_secs(); - for i in 0..self.drones.len() { - let d = &self.drones[i]; - let (ox, oy) = (d.tx - d.x, d.ty - d.y); - let dist = (ox * ox + oy * oy).sqrt(); - if dist < 0.3 { - let (tx, ty) = free_point(ctx); - let d = &mut self.drones[i]; - d.tx = tx; - d.ty = ty; - continue; - } - let d = &mut self.drones[i]; - let stride = (DRONE_SPEED * dt).min(dist); - d.x += ox / dist * stride; - d.y += oy / dist * stride; - d.yaw = oy.atan2(ox); - } - } - - fn replicate(&self, out: &mut WorldView) { - for p in self.players.values() { - out.add(p.entity, &PlayerView::of(&p.state, &p.name)); - } - for d in &self.drones { - let view = DroneView { - x: d.x, - y: d.y, - yaw: d.yaw, - }; - out.add(d.id, &view).priority(0.5); - } - for pillar in Pillar::all() { - out.project::(&pillar); - } - } -} - -/// Bot payload for `distributed_game::bots`: direction → [`MoveInput`]. -pub fn encode_bot_input(dir: [f32; 2]) -> Vec { - let axis = |v: f32| (v.clamp(-1.0, 1.0) * 127.0).round() as i8; - MoveInput { - mx: axis(dir[0]), - my: axis(dir[1]), - buttons: 0, - } - .to_payload() -} - -/// Demo registration (`game-demos arena`). -pub struct ArenaDemo; - -impl Demo for ArenaDemo { - type Zone = Arena; - const NAME: &'static str = "arena"; - const DEFAULT_ZONE: &'static str = "arena-1"; - - fn hosts_zone(zone_id: &str) -> bool { - zone_id.starts_with("arena-") - } - - fn server(config: ServerConfig, guests: Guests) -> GameServer { - GameServer::new( - config, - ZoneFactory::sync(move |activation| { - let zone_id = activation.zone_id.as_str(); - Self::hosts_zone(zone_id).then(|| ArenaConfig { - guests: guests.clone(), - }) - }), - ) - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - use std::time::Duration; - - use distributed_game::{HostConfig, ManualClock, Role, TicketClaims, Viewer, ZoneHost}; - - use super::*; - - #[test] - fn a_reload_keeps_the_body() { - let clock = ManualClock::new(); - let mut host = ZoneHost::::new( - "arena", - HostConfig::default(), - Arc::new(clock.clone()), - ArenaConfig { - guests: Guests::default(), - }, - ); - let claims = TicketClaims::new("ana", "arena", u64::MAX, "n1", Role::Player); - let joined = host.join(&claims, 0, "").expect("join"); - let Viewer::Player(player) = joined.viewer else { - panic!("player") - }; - for seq in 1..=20u32 { - let input = MoveInput { - mx: 127, - my: 0, - buttons: 0, - }; - host.push_input(joined.client, seq, seq, &input.to_payload()); - clock.advance(Duration::from_millis(33)); - host.advance(); - } - let before = host.zone().players[&player].state; - host.disconnect(joined.client, false); - let claims = TicketClaims::new("ana", "arena", u64::MAX, "n2", Role::Player); - let again = host.join(&claims, 0, "").expect("rejoin"); - assert_eq!(again.viewer, Viewer::Player(player)); - clock.advance(Duration::from_millis(33)); - host.advance(); - let after = host.zone().players[&player].state; - assert_eq!((before.x, before.y), (after.x, after.y), "same body"); - } -} diff --git a/examples/game/server/src/common/mod.rs b/examples/game/server/src/common/mod.rs index 0d46c1b5..4964294b 100644 --- a/examples/game/server/src/common/mod.rs +++ b/examples/game/server/src/common/mod.rs @@ -28,7 +28,7 @@ pub use guests::{random_hex, Guests}; pub trait Demo: 'static { /// The zone type. type Zone: Zone; - /// Subcommand / URL path name (`arena` → `/arena/`). + /// Subcommand / URL path name (`gallery` → `/gallery/`). const NAME: &'static str; /// Zone the client joins by default. const DEFAULT_ZONE: &'static str; diff --git a/examples/game/server/src/freeze/content.rs b/examples/game/server/src/freeze/content.rs index 4b41a266..efb014a8 100644 --- a/examples/game/server/src/freeze/content.rs +++ b/examples/game/server/src/freeze/content.rs @@ -422,7 +422,7 @@ mod tests { #[test] fn layout_is_clear_of_pillars() { - use crate::arena::movement::PILLARS; + use game_sim::arena::PILLARS; for kind in [ZoneKind::Hub, ZoneKind::Meadow, ZoneKind::Ember] { let points = kind .portals() diff --git a/examples/game/server/src/freeze/mod.rs b/examples/game/server/src/freeze/mod.rs index a1b4b055..97a028bb 100644 --- a/examples/game/server/src/freeze/mod.rs +++ b/examples/game/server/src/freeze/mod.rs @@ -21,7 +21,7 @@ //! [`Zone::freeze_ready`]; the barrier times out (`NotAcked`) and every //! zone resumes on the old version ("update aborted"). //! -//! Movement is the Arena's step ([`crate::arena::movement::arena_step`]), +//! Movement is the Arena's step ([`movement::arena_step`]), //! which the client predicts with, compiled to WebAssembly. //! //! Entities: 1 `player` (the Arena's [`PlayerView`]), 2 `slime` (x y yaw), @@ -46,10 +46,62 @@ use distributed_game::{ }; use serde::Serialize; -use crate::arena::movement::{self, MoveInput, MoveState}; -use crate::arena::PlayerView; use crate::common::{Demo, Guests}; use content::{Hazard, Pack, ZoneKind}; +/// The shared movement step (Mob Pit's capsule without doorways). +pub use game_sim::arena as movement; +use movement::{MoveInput, MoveState}; + +/// A player's capsule: the full [`MoveState`] (so the client can rebase its +/// prediction) plus the display name. +#[derive(Debug, Clone, PartialEq, Replicated)] +#[replicated(kind = "player", id = 1, priority = 2.0, global)] +pub struct PlayerView { + /// Position x. + #[field_type(movement::POS)] + #[correct(ease = 150)] + pub x: f32, + /// Position y. + #[field_type(movement::POS)] + #[correct(ease = 150)] + pub y: f32, + /// Velocity x. + #[field_type(movement::VEL)] + #[correct(snap)] + pub vx: f32, + /// Velocity y. + #[field_type(movement::VEL)] + #[correct(snap)] + pub vy: f32, + /// Remaining dash frames. + #[correct(snap)] + pub dash: u8, + /// Remaining dash cooldown frames. + #[correct(snap)] + pub cd: u8, + /// Display name. + #[priority(0.2)] + #[correct(replace)] + pub name: String, +} + +impl PlayerView { + /// The view of a capsule. + pub fn of(state: &MoveState, name: &str) -> Self { + Self { + x: state.x, + y: state.y, + vx: state.vx, + vy: state.vy, + dash: state.dash, + cd: state.cd, + name: name.to_string(), + } + } +} + +/// Most command frames a player may bank (absorbs input jitter). +pub const MAX_CREDITS: u8 = 4; /// A wandering slime. #[derive(Debug, Clone, PartialEq, Replicated)] @@ -174,6 +226,7 @@ pub fn game_surface() -> GameSurface { .predict::() .record::() .constant("freeze_world", &content::freeze_world()) + .constant("arena_floor", &movement::arena_floor()) } /// Durable command recording a collected gem. @@ -384,7 +437,7 @@ impl Zone for FreezeZone { config, pack, players: BTreeMap::new(), - credits: InputCredits::new(u32::from(crate::arena::MAX_CREDITS)), + credits: InputCredits::new(u32::from(MAX_CREDITS)), slimes, gems: (0..kind.gem_spots().len()) .map(|spot| Gem { @@ -607,3 +660,14 @@ impl Demo for FreezeDemo { world::zone_server(freeze_config(config), guests, Rituals::default(), "local") } } + +/// Bot payload for `distributed_game::bots`: direction → [`MoveInput`]. +pub fn encode_bot_input(dir: [f32; 2]) -> Vec { + let axis = |v: f32| (v.clamp(-1.0, 1.0) * 127.0).round() as i8; + MoveInput { + mx: axis(dir[0]), + my: axis(dir[1]), + buttons: 0, + } + .to_payload() +} diff --git a/examples/game/server/src/lib.rs b/examples/game/server/src/lib.rs index fd138f03..db0c7dd5 100644 --- a/examples/game/server/src/lib.rs +++ b/examples/game/server/src/lib.rs @@ -2,7 +2,7 @@ //! //! - [`common`]: what every demo shares — the [`common::Demo`] trait, //! startup, dev ticket / NetSim routes, guest names. -//! - One module per demo ([`arena`], …), each implementing `Demo` and +//! - One module per demo ([`mobpit`], …), each implementing `Demo` and //! exporting `game_surface()`: its kinds, input and events, authored with //! the `distributed_game` derives, from which `run.sh gen` generates the //! typed TypeScript client in `client/src/generated//`. @@ -12,7 +12,6 @@ //! //! See `examples/game/README.md`. -pub mod arena; pub mod cluster; pub mod common; pub mod devcelld; diff --git a/examples/game/server/src/main.rs b/examples/game/server/src/main.rs index b523c2a2..c8308116 100644 --- a/examples/game/server/src/main.rs +++ b/examples/game/server/src/main.rs @@ -1,7 +1,6 @@ //! `game-demos [--port N] [--dev]` — one subcommand per demo. use clap::{Parser, Subcommand}; -use game_demos::arena::ArenaDemo; use game_demos::cluster::{run_cluster, ClusterArgs}; use game_demos::common::{run, DemoArgs}; use game_demos::freeze::world::FreezeArgs; @@ -22,8 +21,6 @@ struct Cli { #[derive(Subcommand)] enum DemoCommand { - /// Prediction, reconciliation, interpolation and interest management. - Arena(DemoArgs), /// Non-blocking durable facts, crash recovery, authoritative combat. Mobpit(MobPitArgs), /// Kills/XP leaderboards (same server as `mobpit`). @@ -45,7 +42,6 @@ enum DemoCommand { #[tokio::main] async fn main() -> std::io::Result<()> { match Cli::parse().demo { - DemoCommand::Arena(args) => run::(args).await, DemoCommand::Mobpit(args) | DemoCommand::Leaderboard(args) => mobpit::run(args).await, DemoCommand::Gallery(args) => run::(args).await, DemoCommand::Freeze(args) => game_demos::freeze::world::run(args).await, diff --git a/examples/game/server/src/mobpit/routes.rs b/examples/game/server/src/mobpit/routes.rs index 3191a41a..b356195a 100644 --- a/examples/game/server/src/mobpit/routes.rs +++ b/examples/game/server/src/mobpit/routes.rs @@ -20,6 +20,10 @@ //! - `POST /dev/mobpit/leaderboard/rebuild` — rebuild both boards from the //! event history of every crawler in the directory. //! - `GET /dev/mobpit/presence?principal=` — the player's presence cell. +//! - `GET /dev/netsim` / `POST /dev/netsim` `{latencyMs, jitterMs, loss}` — +//! read / replace the network simulation of **every** room (dev settings +//! object on celld, read by each room within a second; applied per +//! socket in both directions, so the RTT grows by 2 × latency). use std::collections::HashMap; use std::sync::Arc; @@ -30,6 +34,7 @@ use axum::http::StatusCode; use axum::routing::{get, post}; use axum::{Json, Router}; use distributed_game::net::TicketKey; +use distributed_game::netsim::{NetSim, NetSimJson, NETSIM_SHARD}; use distributed_game::{Role, TicketClaims}; use serde_json::{json, Value}; @@ -67,6 +72,7 @@ pub fn routes(world: Arc, key: TicketKey) -> Router { .route("/dev/mobpit/leaderboard", get(leaderboard)) .route("/dev/mobpit/leaderboard/rebuild", post(rebuild)) .route("/dev/mobpit/presence", get(presence)) + .route("/dev/netsim", get(get_netsim).post(set_netsim)) .with_state(Dev { world, key }) } @@ -198,3 +204,28 @@ async fn presence( .map_err(internal)?; Ok(Json(json!({ "principal": principal, "presence": current }))) } + +async fn get_netsim(State(dev): State) -> Result, (StatusCode, Json)> { + let value = dev + .world + .cells() + .dev_settings(NETSIM_SHARD) + .await + .map_err(internal)?; + Ok(Json(serde_json::from_value(value).unwrap_or_default())) +} + +async fn set_netsim( + State(dev): State, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + // Bounded and normalized (what the rooms will apply). + let applied = NetSim::to_json(body.to_sim()); + let value = serde_json::to_value(applied).map_err(internal)?; + dev.world + .cells() + .set_dev_settings(NETSIM_SHARD, &value) + .await + .map_err(internal)?; + Ok(Json(applied)) +} diff --git a/examples/game/server/src/mobpit/store.rs b/examples/game/server/src/mobpit/store.rs index 28a5d66c..bc76e838 100644 --- a/examples/game/server/src/mobpit/store.rs +++ b/examples/game/server/src/mobpit/store.rs @@ -126,6 +126,38 @@ impl CrawlerCells { Ok(body) } + /// The rooms' dev settings under `shard` (`distributed_game::celld_zone`'s + /// `zone-dev` object; `{}` when unset). + pub async fn dev_settings(&self, shard: &str) -> Result { + self.get(distributed_game::netsim::ZONE_DEV_KIND, shard) + .await + } + + /// Replace the rooms' dev settings under `shard` (every room reads them + /// within a second). + pub async fn set_dev_settings(&self, shard: &str, value: &Value) -> Result { + let url = format!( + "{}/{}/{}", + self.base, + distributed_game::netsim::ZONE_DEV_KIND, + encode_segment(shard) + ); + let response = self + .client + .put(&url) + .header(CELL_INTERNAL_SECRET_HEADER, self.secret.header_value()) + .json(value) + .send() + .await + .map_err(|error| format!("celld unreachable at {url}: {error}"))?; + let status = response.status(); + let body: Value = response.json().await.unwrap_or(Value::Null); + if !status.is_success() { + return Err(format!("PUT {url}: HTTP {status}: {body}")); + } + Ok(body) + } + /// The durable presence entry of `principal` (`null` when none). pub async fn presence(&self, principal: &str) -> Result { Ok(self.get(PRESENCE_KIND, principal).await?["current"].take()) diff --git a/examples/game/server/src/ripple/mod.rs b/examples/game/server/src/ripple/mod.rs index 71c5ee80..1f4a4c4c 100644 --- a/examples/game/server/src/ripple/mod.rs +++ b/examples/game/server/src/ripple/mod.rs @@ -53,9 +53,9 @@ use distributed_game::{ use serde::{Deserialize, Serialize}; use serde_json::json; -use crate::arena::movement::{self, MoveState}; use crate::cluster::{ClusterDemo, World}; use crate::common::{Demo, Guests}; +use game_sim::arena::{self as movement, MoveState}; /// Root zone. pub const REALM: &str = "realm"; @@ -214,6 +214,7 @@ pub fn game_surface() -> GameSurface { .events::() .predict::() .constant("ripple_world", &ripple_world()) + .constant("arena_floor", &game_sim::arena::arena_floor()) } /// Kind id of [`TierView`] (tests mirror fields by it). diff --git a/examples/game/server/tests/arena_smoke.rs b/examples/game/server/tests/arena_smoke.rs deleted file mode 100644 index 316d725b..00000000 --- a/examples/game/server/tests/arena_smoke.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! Arena smoke tests: the real server in-process, dev routes over HTTP and -//! 20 WebSocket bots (`distributed_game::bots`). - -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use distributed_game::bots::{run_bots, BotConfig, RandomWalk}; -use distributed_game::net::TicketKey; -use game_demos::arena::{self, ArenaDemo}; -use game_demos::common::{spawn, DemoOptions, RunningDemo}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; - -const SECRET: &str = "smoke-secret"; - -async fn start(options: DemoOptions) -> (RunningDemo, tokio::sync::oneshot::Sender<()>) { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let (stop, stopped) = tokio::sync::oneshot::channel::<()>(); - let running = spawn::(listener, options, async move { - let _ = stopped.await; - }) - .unwrap(); - (running, stop) -} - -/// Minimal HTTP/1.1 client: `(status, JSON body or Null)`. -async fn http(addr: SocketAddr, method: &str, path: &str, body: Option) -> (u16, Value) { - let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap(); - let body = body.map(|b| b.to_string()).unwrap_or_default(); - let request = format!( - "{method} {path} HTTP/1.1\r\nHost: test\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", - body.len() - ); - stream.write_all(request.as_bytes()).await.unwrap(); - let mut response = String::new(); - stream.read_to_string(&mut response).await.unwrap(); - let status = response[9..12].parse().unwrap(); - let json = response - .split_once("\r\n\r\n") - .and_then(|(_, b)| serde_json::from_str(b).ok()) - .unwrap_or(Value::Null); - (status, json) -} - -fn now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn dev_ticket_binds_a_server_generated_guest() { - let (demo, stop) = start(DemoOptions::dev(SECRET)).await; - let key = TicketKey::new(SECRET); - - let (status, first) = http( - demo.addr, - "POST", - "/dev/ticket", - Some(json!({ "zone": "arena-1", "name": " Ana " })), - ) - .await; - assert_eq!(status, 200, "{first}"); - let principal = first["principal"].as_str().unwrap().to_string(); - assert!(principal.starts_with("guest-")); - assert_eq!(first["name"], "bAnab"); - let claims = key - .verify(first["ticket"].as_str().unwrap(), now()) - .unwrap(); - assert_eq!(claims.principal, principal); - assert_eq!(claims.zone, "arena-1"); - assert_eq!(demo.guests.name(&principal).as_deref(), Some("bAnab")); - - // The signed guest token keeps the principal; a caller identity never does. - let (_, again) = http( - demo.addr, - "POST", - "/dev/ticket", - Some(json!({ "zone": "arena-1", "guest": first["guest"] })), - ) - .await; - assert_eq!(again["principal"], principal.as_str()); - assert_eq!(again["name"], "bAnab", "name remembered"); - let (_, forged) = http( - demo.addr, - "POST", - "/dev/ticket", - Some(json!({ "zone": "arena-1", "guest": "guest-admin.00" })), - ) - .await; - assert_ne!(forged["principal"], "guest-admin"); - let (status, _) = http( - demo.addr, - "POST", - "/dev/ticket", - Some(json!({ "zone": "arena-1", "principal": "admin" })), - ) - .await; - assert_eq!( - status, 422, - "unknown fields (a caller identity) are rejected" - ); - let (status, _) = http( - demo.addr, - "POST", - "/dev/ticket", - Some(json!({ "zone": "pit-1" })), - ) - .await; - assert_eq!(status, 400, "zones of other demos are refused"); - - // Live NetSim control. - let (status, sim) = http( - demo.addr, - "POST", - "/dev/netsim", - Some(json!({ "latencyMs": 40, "jitterMs": 10, "loss": 0.9 })), - ) - .await; - assert_eq!(status, 200); - assert_eq!(sim, json!({ "latencyMs": 40, "jitterMs": 10, "loss": 0.5 })); - assert_eq!( - demo.server.net_sim().unwrap().latency, - Duration::from_millis(40) - ); - let (_, off) = http(demo.addr, "POST", "/dev/netsim", Some(json!({}))).await; - assert_eq!(off, json!({ "latencyMs": 0, "jitterMs": 0, "loss": 0.0 })); - assert_eq!(demo.server.net_sim(), None); - - stop.send(()).unwrap(); - demo.task.await.unwrap().unwrap(); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn dev_routes_are_off_by_default() { - let options = DemoOptions { - dev: false, - ..DemoOptions::dev(SECRET) - }; - let (demo, stop) = start(options).await; - let (status, _) = http( - demo.addr, - "POST", - "/dev/ticket", - Some(json!({ "zone": "arena-1" })), - ) - .await; - assert_eq!(status, 404); - let (status, _) = http(demo.addr, "GET", "/metrics", None).await; - assert_eq!(status, 200, "metrics are always served"); - stop.send(()).unwrap(); - demo.task.await.unwrap().unwrap(); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn twenty_bots_get_deltas_and_acks() { - let (demo, stop) = start(DemoOptions::dev(SECRET)).await; - let mut config = BotConfig::new(demo.ws_url(), "arena-1", TicketKey::new(SECRET)); - config.bots = 20; - config.input_rate = 30.0; - config.duration = Duration::from_secs(2); - config.encoder = Arc::new(arena::encode_bot_input); - let server = demo.server.clone(); - let (summary, players) = tokio::join!( - run_bots(config, |_| Box::new(RandomWalk::new(0.1))), - async move { - tokio::time::sleep(Duration::from_millis(1200)).await; - server.inspect("arena-1", |host| host.player_count()).await - } - ); - assert_eq!(players, Some(20), "all bots are players mid-run"); - assert_eq!( - summary.joined(), - 20, - "{:?}", - summary.bots.iter().find(|b| b.error.is_some()) - ); - assert_eq!(summary.failed(), 0); - for bot in &summary.bots { - assert!( - bot.deltas >= 20, - "bot {} got {} deltas", - bot.index, - bot.deltas - ); - assert!(bot.last_ack >= 20, "bot {} ack {}", bot.index, bot.last_ack); - assert!(bot.entities >= 9, "pillars are global: {}", bot.entities); - } - assert!(summary.delta_bytes() > 0); - let metrics = demo.server.metrics().render(); - assert!(metrics.contains("arena-1"), "per-zone metrics"); - stop.send(()).unwrap(); - demo.task.await.unwrap().unwrap(); -} diff --git a/examples/game/server/tests/freeze_smoke.rs b/examples/game/server/tests/freeze_smoke.rs index fc341304..2ac761c8 100644 --- a/examples/game/server/tests/freeze_smoke.rs +++ b/examples/game/server/tests/freeze_smoke.rs @@ -10,7 +10,7 @@ use distributed_game::bots::{run_bots, BotConfig, RandomWalk}; use distributed_game::net::TicketKey; use distributed_game::world::HostAddr; use distributed_game::FreezeState; -use game_demos::arena; + use game_demos::freeze::content::{Hazard, Pack, EMBER, HUB, MEADOW}; use game_demos::freeze::world::{assignment, start_host, HostSpec, WorldHost}; use serde_json::{json, Value}; @@ -125,7 +125,7 @@ fn bots(url: String, zone: &str) -> BotConfig { config.duration = Duration::from_millis(2600); config.content_version = "v1".into(); config.principal_prefix = format!("bot-{zone}"); - config.encoder = Arc::new(arena::encode_bot_input); + config.encoder = Arc::new(game_demos::freeze::encode_bot_input); config } diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts b/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts index 2b523df3..b1acf0eb 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts @@ -20,6 +20,19 @@ export const PIT_RULES: PitRules = { export const PIT_WORLD: PitWorld = { defaultSeed: 186474221, half: 20, + playerRadius: 0.5, + dashCooldown: 45, + pillars: [ + { x: 0, y: 0, r: 2.5 }, + { x: -9, y: -9, r: 1.5 }, + { x: 9, y: -9, r: 1.5 }, + { x: -9, y: 9, r: 1.5 }, + { x: 9, y: 9, r: 1.5 }, + { x: 0, y: -14, r: 1 }, + { x: 0, y: 14, r: 1 }, + { x: -14, y: 0, r: 1 }, + { x: 14, y: 0, r: 1 } + ], doorHalf: 2.5, roomSize: 40, passage: 2, diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/events.ts b/examples/game/sveltekit/src/lib/generated/mobpit/events.ts index b9815227..4a305927 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/events.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/events.ts @@ -20,6 +20,13 @@ export interface MoveState { readonly cd: number; } +/** Record `Pillar` (`#[derive(GameRecord)]`). */ +export interface Pillar { + readonly x: number; + readonly y: number; + readonly r: number; +} + /** Record `PitRoomContext` (`#[derive(GameRecord)]`). */ export interface PitRoomContext { readonly seed: number; @@ -54,6 +61,9 @@ export interface PitRules { export interface PitWorld { readonly defaultSeed: number; readonly half: number; + readonly playerRadius: number; + readonly dashCooldown: number; + readonly pillars: readonly Pillar[]; readonly doorHalf: number; readonly roomSize: number; readonly passage: number; @@ -95,6 +105,7 @@ export interface HelloEvent { readonly seed: number; readonly restored: boolean; readonly roomKills: number; + readonly viewRadius: number; } const HELLO_LAYOUT: PayloadLayout = [ @@ -107,6 +118,7 @@ const HELLO_LAYOUT: PayloadLayout = [ ['seed', { type: 'u32' }], ['restored', { type: 'bool' }], ['roomKills', { type: 'u32' }], + ['viewRadius', { type: 'f32' }], ]; /** Event `profile` (id 2). */ diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/surface.json b/examples/game/sveltekit/src/lib/generated/mobpit/surface.json index 4299b39e..8fa40e36 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/surface.json +++ b/examples/game/sveltekit/src/lib/generated/mobpit/surface.json @@ -50,12 +50,61 @@ "forest", "meadow" ], + "dash_cooldown": 45, "default_seed": 186474221, "door_half": 2.5, "half": 20.0, "hysteresis": 0.5, "max_room": 1000000, "passage": 2.0, + "pillars": [ + { + "r": 2.5, + "x": 0.0, + "y": 0.0 + }, + { + "r": 1.5, + "x": -9.0, + "y": -9.0 + }, + { + "r": 1.5, + "x": 9.0, + "y": -9.0 + }, + { + "r": 1.5, + "x": -9.0, + "y": 9.0 + }, + { + "r": 1.5, + "x": 9.0, + "y": 9.0 + }, + { + "r": 1.0, + "x": 0.0, + "y": -14.0 + }, + { + "r": 1.0, + "x": 0.0, + "y": 14.0 + }, + { + "r": 1.0, + "x": -14.0, + "y": 0.0 + }, + { + "r": 1.0, + "x": 14.0, + "y": 0.0 + } + ], + "player_radius": 0.5, "room_size": 40.0 } } @@ -119,6 +168,12 @@ "type": { "type": "u32" } + }, + { + "name": "view_radius", + "type": { + "type": "f32" + } } ], "kind": "fields" @@ -745,6 +800,29 @@ ], "name": "MoveState" }, + { + "fields": [ + { + "name": "x", + "type": { + "type": "f32" + } + }, + { + "name": "y", + "type": { + "type": "f32" + } + }, + { + "name": "r", + "type": { + "type": "f32" + } + } + ], + "name": "Pillar" + }, { "fields": [ { @@ -851,6 +929,28 @@ "type": "f32" } }, + { + "name": "player_radius", + "type": { + "type": "f32" + } + }, + { + "name": "dash_cooldown", + "type": { + "type": "u8" + } + }, + { + "name": "pillars", + "type": { + "item": { + "name": "Pillar", + "type": "record" + }, + "type": "list" + } + }, { "name": "door_half", "type": { diff --git a/examples/game/sveltekit/src/lib/pit/loop.ts b/examples/game/sveltekit/src/lib/pit/loop.ts index ae93a998..65e618f4 100644 --- a/examples/game/sveltekit/src/lib/pit/loop.ts +++ b/examples/game/sveltekit/src/lib/pit/loop.ts @@ -73,6 +73,8 @@ export type PitLoopOptions = Readonly<{ hello: () => HelloEvent | undefined; /** Steer to the nearest mob and swing (demo and headless test). */ autoHunt: () => boolean; + /** Draw the server's authoritative copy of you (the ghost). */ + ghost?: () => boolean; }>; /** Start drawing on `canvas`; returns the stop function. */ @@ -205,6 +207,35 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) } } } + // Interest ring: the room replicates mobs and players inside it only. + if (me !== undefined && hello !== undefined) { + const [mx, my] = at(me.x, me.y); + ctx.beginPath(); + ctx.arc(mx, my, hello.viewRadius * scale, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(108,196,255,0.35)'; + ctx.setLineDash([6, 6]); + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.setLineDash([]); + } + // The ghost: the room's authoritative copy of you (behind by the round trip). + const authoritative = client.predictor?.authoritative(); + const ghostZone = client.authority?.zoneId; + const ghostRoom = parseRoom(ghostZone); + if (authoritative !== undefined && ghostRoom !== undefined) { + const g = playerFields({ fields: authoritative }); + const gw = { x: ghostRoom[0] * ROOM + g.x, y: ghostRoom[1] * ROOM + g.y }; + if (me !== undefined) canvas.dataset.ghostGap = Math.hypot(gw.x - me.x, gw.y - me.y).toFixed(3); + if (options.ghost?.() ?? true) { + const [gx, gy] = at(gw.x, gw.y); + ctx.beginPath(); + ctx.arc(gx, gy, PLAYER_RADIUS * scale, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255,255,255,0.8)'; + ctx.setLineDash([3, 3]); + ctx.stroke(); + ctx.setLineDash([]); + } + } ctx.lineWidth = 1; for (const entity of rendered) { const w = worldOf(entity); diff --git a/examples/game/sveltekit/src/routes/api/dev/netsim/+server.ts b/examples/game/sveltekit/src/routes/api/dev/netsim/+server.ts new file mode 100644 index 00000000..141f4080 --- /dev/null +++ b/examples/game/sveltekit/src/routes/api/dev/netsim/+server.ts @@ -0,0 +1,46 @@ +/** + * **Dev only** (`GAME_DEV_AUTH=1`, signed in): the network simulation every + * Mob Pit room on celld applies to its sockets (latency, jitter, loss), kept + * in the cells worker's dev settings object (`zone-dev/netsim`, read by each + * room within a second). Server-wide: it affects every player. + */ +import { error, json } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import type { RequestHandler } from './$types'; + +type NetSim = { latencyMs: number; jitterMs: number; loss: number }; + +function bounded(body: Partial): NetSim { + const ms = (v: unknown, max: number) => (typeof v === 'number' && Number.isFinite(v) ? Math.min(max, Math.max(0, Math.round(v))) : 0); + const loss = typeof body.loss === 'number' && Number.isFinite(body.loss) ? Math.min(0.5, Math.max(0, body.loss)) : 0; + return { latencyMs: ms(body.latencyMs, 2000), jitterMs: ms(body.jitterMs, 2000), loss }; +} + +async function devSettings(init?: RequestInit): Promise { + const base = env.CELLD_URL; + const secret = env.DISTRIBUTED_INTERNAL_SECRET; + if (!base || !secret) error(503, 'celld is not configured'); + const response = await fetch(`${base.replace(/\/$/, '')}/zone-dev/netsim`, { + ...init, + headers: { 'x-distributed-internal-secret': secret, 'content-type': 'application/json' }, + signal: AbortSignal.timeout(3000) + }); + if (!response.ok) error(502, `dev settings: HTTP ${response.status}`); + return bounded((await response.json()) as Partial); +} + +async function guard(locals: App.Locals): Promise { + if (env.GAME_DEV_AUTH !== '1') error(404, 'not found'); + if (!(await locals.auth())?.user) error(401, 'sign in first'); +} + +export const GET: RequestHandler = async ({ locals }) => { + await guard(locals); + return json(await devSettings()); +}; + +export const POST: RequestHandler = async ({ locals, request }) => { + await guard(locals); + const settings = bounded((await request.json()) as Partial); + return json(await devSettings({ method: 'PUT', body: JSON.stringify(settings) })); +}; diff --git a/examples/game/sveltekit/src/routes/pit/+page.svelte b/examples/game/sveltekit/src/routes/pit/+page.svelte index 5186741b..c69ca3cb 100644 --- a/examples/game/sveltekit/src/routes/pit/+page.svelte +++ b/examples/game/sveltekit/src/routes/pit/+page.svelte @@ -68,11 +68,51 @@ let canvas = $state(); let autoHunt = $state(false); + let ghost = $state(true); + let predict = $state(true); $effect(() => { const client = game.client; if (client === undefined || canvas === undefined) return; - return startPitLoop(canvas, { client, hello: () => hello.current, autoHunt: () => autoHunt }); + return startPitLoop(canvas, { client, hello: () => hello.current, autoHunt: () => autoHunt, ghost: () => ghost }); }); + $effect(() => game.client?.setPredictionEnabled(predict)); + + // Network panel: prediction / reconciliation readout and the rooms' + // network simulation (dev only: every room on celld delays its sockets). + let net = $state({ pending: 0, ack: 0, corrections: 0, last: 0 }); + $effect(() => { + const client = game.client; + if (client === undefined) return; + let corrections = 0; + let last = 0; + const off = client.on('correction', (c) => { + corrections += 1; + last = Math.hypot(c.errors.x ?? 0, c.errors.y ?? 0); + }); + const timer = setInterval(() => { + const p = client.predictor; + net = { pending: p?.pending.length ?? 0, ack: p?.lastAck ?? 0, corrections, last }; + }, 250); + return () => { + clearInterval(timer); + off(); + }; + }); + let sim = $state({ latencyMs: 0, jitterMs: 0, loss: 0 }); + let simStatus = $state(''); + $effect(() => { + fetch('/api/dev/netsim') + .then(async (r) => (r.ok ? (sim = await r.json()) : (simStatus = `dev routes off (${r.status})`))) + .catch(() => (simStatus = 'unavailable')); + }); + let simTimer: ReturnType | undefined; + function pushSim() { + clearTimeout(simTimer); + simTimer = setTimeout(async () => { + const r = await fetch('/api/dev/netsim', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sim) }); + simStatus = r.ok ? 'applied to every room' : `failed (${r.status})`; + }, 150); + } // Kills the zone reported that the cell has not acknowledged yet. const unsaved = $derived( @@ -92,6 +132,18 @@ WASD move · Space attack · E dash · walk through a doorway (the gaps in the walls) to the next room +
    +

    Network

    + + +
    pending inputs {net.pending} · ack {net.ack}
    +
    corrections {net.corrections} (last {net.last.toFixed(3)} m)
    +
    interest ring {hello.current?.viewRadius ?? '–'} m
    + + + + {simStatus} +

    Rooms

    @@ -131,6 +183,7 @@ canvas { background: #120f14; border: 1px solid #2b2533; max-width: 100%; } aside { min-width: 16rem; display: grid; gap: 1rem; align-content: start; } section { border: 1px solid #2b2533; border-radius: 6px; padding: 0.75rem; } + section label { display: block; } h2 { margin: 0 0 0.5rem; font-size: 1rem; } .error { color: #ff6b6b; } .saved { color: #9ad16b; } diff --git a/examples/game/sveltekit/tests/pit.test.mjs b/examples/game/sveltekit/tests/pit.test.mjs index 77060874..aebf117a 100644 --- a/examples/game/sveltekit/tests/pit.test.mjs +++ b/examples/game/sveltekit/tests/pit.test.mjs @@ -14,7 +14,8 @@ * (toast, minimap active) → a player who reloads, or closes the browser and * signs in again, is the same player in the same room at the same place → * the SSR HTML after a reload has the settled - * numbers → no page errors. The leaderboard is the services' Redis XP board, + * numbers → the network panel's latency slider raises the RTT on every room + * (and the server ghost trails the predicted player) → no page errors. The leaderboard is the services' Redis XP board, * served by mobpit-service's GraphQL board roots (SSR-seeded, `@live` from * the board change notices on the bus). Needs REDIS_URL. * @@ -283,6 +284,24 @@ test('sign in, join, move, kill, walk through rooms on celld, live read models f const moved = await page.evaluate(() => document.querySelector('canvas').dataset.me.split(',').map(Number)); assert.ok(moved[0] > start[0] + 0.5 || Math.hypot(moved[0] - start[0], moved[1] - start[1]) > 0.5, `moved ${start} → ${moved}`); + // Network panel: the latency slider sets the rooms' simulated network + // (dev route → the cells worker's dev settings → every room on celld + // delays its sockets); the RTT rises, the server ghost trails the + // predicted player while walking, and it all drops back. + const rtt = () => page.textContent('[data-testid=rtt]').then(Number); + const baseRtt = await rtt(); + assert.equal((await fetch(`${ORIGIN}/api/dev/netsim`)).status, 401, 'signed-out: no network control'); + await page.locator('[data-testid=latency]').fill('100'); + await page.waitForSelector('[data-testid=netsim-status]:text-is("applied to every room")', { timeout: 5_000 }); + await page.waitForFunction((b) => Number(document.querySelector('[data-testid=rtt]')?.textContent) > b + 150, baseRtt, { timeout: 15_000 }); + await page.keyboard.down('KeyA'); + await page.waitForFunction(() => Number(document.querySelector('canvas')?.dataset.ghostGap ?? 0) > 0.3, null, { timeout: 5_000 }); + await page.keyboard.up('KeyA'); + console.error(`network panel: rtt ${baseRtt} → ${await rtt()} ms with 100 ms one way, ghost gap seen > 0.3 m`); + await page.locator('[data-testid=latency]').fill('0'); + await page.waitForFunction((b) => Number(document.querySelector('[data-testid=rtt]')?.textContent) < b + 60, baseRtt, { timeout: 15_000 }); + assert.ok(Number(await page.textContent('[data-testid=corrections]')) >= 0); + // Attack: the server's swing reaches the client and the cone is drawn. await page.keyboard.down('Space'); await page.waitForFunction(() => Number(document.querySelector('canvas')?.dataset.swings ?? 0) > 0, null, { timeout: 5_000 }); From 1cb772204ba076c5e4a74cc304cdf3b6e1b6088f Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 21 Oct 2026 00:16:10 -0500 Subject: [PATCH 03/21] feat(game)!: passive watchers on celld zones, SDK watchZones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On zones hosted as celld objects an observer is a passive watcher: it never counts as occupancy (a zone with only observers stops ticking, goes dormant and hibernates, closing their sockets) and never wakes a sleeping zone — its Hello is refused with the new error code ASLEEP (10). Only a player's Hello wakes a zone (the WebSocket is accepted before waking); a resume to a cold zone is RECONNECT_FAILED without waking. A watcher joining a dormant zone is greeted with its current state (`ZoneHost::patch_now`). SDK: `watchZones(client, around)` keeps the zones around the authority observed across handoffs, lets go of closed watchers and retries them with back-off; `GameErrorCode.ASLEEP`. The celld_zone integration tests gain `watchers` and run one celld at a time. Refs [[tasks/gr-p44-mobpit-world]] --- distributed_game/PROTOCOL.md | 5 +- distributed_game/src/celld_zone/mod.rs | 9 ++ distributed_game/src/celld_zone/object.rs | 61 +++++++++-- distributed_game/src/host.rs | 9 ++ distributed_game/src/protocol/messages.rs | 3 + distributed_game/tests/celld_zone/main.rs | 82 +++++++++++++- js/README.md | 6 +- js/src/game/index.ts | 3 + js/src/game/messages.ts | 4 +- js/src/game/watch.ts | 125 ++++++++++++++++++++++ js/tests/game-watch.test.mjs | 89 +++++++++++++++ 11 files changed, 382 insertions(+), 14 deletions(-) create mode 100644 js/src/game/watch.ts create mode 100644 js/tests/game-watch.test.mjs diff --git a/distributed_game/PROTOCOL.md b/distributed_game/PROTOCOL.md index 568c8a6c..02e4ea2c 100644 --- a/distributed_game/PROTOCOL.md +++ b/distributed_game/PROTOCOL.md @@ -309,7 +309,9 @@ Redirect := 0x89, string zone_id, string url - **Error**: codes `1` BAD_MESSAGE, `2` UNAUTHORIZED, `3` REJECTED, `4` ZONE_FULL, `5` VERSION_MISMATCH, `6` RATE_LIMITED, `7` NOT_FOUND, `8` RECONNECT_FAILED, `9` ZONE_UNAVAILABLE (the zone could not be created - right now; retry later). Errors during the handshake are followed by close; + right now; retry later), `10` ASLEEP (an observer is not admitted to a + sleeping zone it would otherwise wake; zones hosted as celld objects treat + observers as passive watchers; retry later). Errors during the handshake are followed by close; `BAD_MESSAGE` and `RATE_LIMITED` after joining are not. Worked example — Welcome (session "s1", zone "z1", player, 30 Hz ticks, @@ -562,6 +564,7 @@ error codes and treat them as "session over" / "request failed"): - `0x89 Redirect` (server → client). - Kick code `7` RELOAD. - Error code `9` ZONE_UNAVAILABLE. +- Error code `10` ASLEEP. - Transfer tickets (§8.1) and content-version policies (§8.2); `Handoff` and `Freeze`/`Resume` encodings are unchanged. diff --git a/distributed_game/src/celld_zone/mod.rs b/distributed_game/src/celld_zone/mod.rs index 03dc3369..cd495323 100644 --- a/distributed_game/src/celld_zone/mod.rs +++ b/distributed_game/src/celld_zone/mod.rs @@ -55,6 +55,15 @@ //! loses at most the snapshot interval of zone state (facts are kept by //! the outbox) and takes the node lease TTL (~10 s) to recover. //! +//! **Observers are passive watchers** on celld: an observer connection +//! (a ticket with role `observer`, e.g. a page watching the rooms next to +//! its own) never counts as occupancy — a zone with only observers stops +//! ticking (they keep its last state), goes dormant and hibernates (their +//! sockets are closed) — and an observer Hello never wakes a sleeping zone: +//! it is refused with [`error::ASLEEP`](crate::protocol::error::ASLEEP). +//! Only a player wakes a zone. A watcher joining a dormant zone gets its +//! current state at once ([`ZoneHost::patch_now`](crate::ZoneHost::patch_now)). +//! //! Dev only: with `ZONE_DEV_SETTINGS=1` every zone object applies the //! network simulation ([`NetSim`](crate::netsim::NetSim): latency, jitter, //! loss per socket and direction) stored in the worker's diff --git a/distributed_game/src/celld_zone/object.rs b/distributed_game/src/celld_zone/object.rs index 3c86293f..c447e111 100644 --- a/distributed_game/src/celld_zone/object.rs +++ b/distributed_game/src/celld_zone/object.rs @@ -40,6 +40,7 @@ use crate::world::zone_activity::{ zone_principal, RecordActivity, ACTIVITY_ACTIVE, ACTIVITY_DORMANT, ACTIVITY_HIBERNATED, ZONE_ACTIVITY_KIND, }; +use crate::zone::Viewer; use crate::{ClientId, Clock, HostConfig, Outbound, TicketClaims, Zone, ZoneHost}; /// A future that runs on the object's (single) thread. @@ -530,10 +531,10 @@ impl Inner { self.in_lines.remove(&client); } + /// Players keep a zone awake (also inside their reconnect grace); + /// observers are passive watchers and never do. fn occupied(&self) -> bool { - self.host - .as_ref() - .is_some_and(|h| h.player_count() > 0 || h.client_count() > 0) + self.host.as_ref().is_some_and(|h| h.player_count() > 0) } fn flush(&mut self) { @@ -723,7 +724,7 @@ impl ZoneObject { if self.inner.borrow().options.is_none() { return Response::error("zone host is not configured", 503); } - self.wake().await?; + // Not woken here: only a player's Hello wakes a sleeping zone. let pair = WebSocketPair::new()?; self.state.accept_web_socket(&pair.server); Response::from_websocket(pair.client) @@ -1117,8 +1118,11 @@ impl ZoneObject { reject(ws, error::VERSION_MISMATCH, "protocol version"); return Ok(()); } - if let Err(e) = self.wake().await { - reject(ws, error::NOT_FOUND, &e.to_string()); + let cold = self.inner.borrow().host.is_none(); + if cold && !hello.reconnect_token.is_empty() { + // Sessions die with the activation: rejoin with a ticket (which + // wakes the zone for a player, not for an observer). + reject(ws, error::RECONNECT_FAILED, "zone restarted; rejoin"); return Ok(()); } let now = (now_ms() / 1000.0) as u64; @@ -1146,6 +1150,11 @@ impl ZoneObject { reject(ws, error::UNAUTHORIZED, "ticket is for another zone"); return Ok(()); } + // A passive watcher never wakes a sleeping zone. + if cold && claims.role == Role::Observer { + reject(ws, error::ASLEEP, "zone asleep"); + return Ok(()); + } let replayed = self .inner .borrow_mut() @@ -1173,9 +1182,19 @@ impl ZoneObject { .put(&nonce_key, claims.expires_at) .await; } - principal = Some(claims.principal.clone()); + if let Err(e) = self.wake().await { + reject(ws, error::NOT_FOUND, &e.to_string()); + return Ok(()); + } + if claims.role == Role::Player { + principal = Some(claims.principal.clone()); + } let mut inner = self.inner.borrow_mut(); - let host = inner.host.as_mut().expect("woken"); + let Some(host) = inner.host.as_mut() else { + drop(inner); + reject(ws, error::ZONE_UNAVAILABLE, "zone asleep"); + return Ok(()); + }; match &transfer { Some(transfer) => { host.join_transfer(transfer, hello.capabilities, &hello.content_version) @@ -1184,7 +1203,11 @@ impl ZoneObject { } } else { let mut inner = self.inner.borrow_mut(); - let host = inner.host.as_mut().expect("woken"); + let Some(host) = inner.host.as_mut() else { + drop(inner); + reject(ws, error::RECONNECT_FAILED, "zone restarted; rejoin"); + return Ok(()); + }; host.reconnect_with_version( &hello.reconnect_token, hello.capabilities, @@ -1193,6 +1216,7 @@ impl ZoneObject { }; match joined { Ok(joined) => { + let observer = matches!(joined.viewer, Viewer::Observer(_)); { let mut inner = self.inner.borrow_mut(); let activation = inner.activation.clone(); @@ -1201,12 +1225,22 @@ impl ZoneObject { activation, })?; inner.sockets.insert(joined.client.0, ws.clone()); + if observer && !inner.ticking { + // A dormant zone does not tick: greet the watcher + // with its current state (it stays dormant). + if let Some(host) = inner.host.as_mut() { + host.patch_now(); + } + } inner.flush(); - if inner.state != RoomState::Active { + if !observer && inner.state != RoomState::Active { inner.state = RoomState::Active; inner.report(ACTIVITY_ACTIVE); } } + if observer { + return Ok(()); + } self.store_status().await; self.start_loop(); if let Some(principal) = principal { @@ -1364,6 +1398,13 @@ impl ZoneObject { return; } inner.report(ACTIVITY_HIBERNATED); + // Watchers of a sleeping zone are let go (they may come back + // and are told it is asleep). + for (_, ws) in inner.sockets.drain() { + let _ = ws.close(Some(1000), Some("zone asleep")); + } + inner.out_lines.clear(); + inner.in_lines.clear(); // Dropped, so the next join restores from storage. inner.host = None; inner.state = RoomState::Cold; diff --git a/distributed_game/src/host.rs b/distributed_game/src/host.rs index 9d798bae..380bc0d8 100644 --- a/distributed_game/src/host.rs +++ b/distributed_game/src/host.rs @@ -1039,6 +1039,15 @@ impl ZoneHost { self.core.server_time_ms() } + /// Send every client the current state now, without ticking (a host + /// that is not ticking — a celld zone with only passive observers — + /// greets a new observer with it). + pub fn patch_now(&mut self) { + if !self.core.clients.is_empty() { + self.patch(); + } + } + /// Frames and closes produced since the last call, in order. pub fn drain_outbound(&mut self) -> Vec { std::mem::take(&mut self.core.outbound) diff --git a/distributed_game/src/protocol/messages.rs b/distributed_game/src/protocol/messages.rs index c0a010ab..d8024612 100644 --- a/distributed_game/src/protocol/messages.rs +++ b/distributed_game/src/protocol/messages.rs @@ -80,6 +80,9 @@ pub mod error { /// The zone could not be created right now (its factory or allocation /// store failed or timed out); retry later. pub const ZONE_UNAVAILABLE: u32 = 9; + /// The zone is asleep and an observer does not wake it (zones hosted as + /// celld objects: observers are passive watchers); try again later. + pub const ASLEEP: u32 = 10; } /// Client handshake. diff --git a/distributed_game/tests/celld_zone/main.rs b/distributed_game/tests/celld_zone/main.rs index 37b9f501..9d7a60a4 100644 --- a/distributed_game/tests/celld_zone/main.rs +++ b/distributed_game/tests/celld_zone/main.rs @@ -1,4 +1,5 @@ //! Zones as celld Durable Objects (`celld_zone`), end to end against +#![allow(clippy::await_holding_lock)] // one celld at a time (`serial`) //! `tests/celld/zone-worker` (a tally zone, its Tally cells and its //! ZoneActivity cell): //! @@ -6,6 +7,10 @@ //! the player's cell, the empty zone goes dormant then hibernated, a new //! join wakes it with its state, tickets and transfer tickets are single //! use, a doorway hands the player to the next zone. +//! - `watchers`: `celld dev`. Observers are passive watchers: one is +//! refused (`ASLEEP`) by a zone that never ran or sleeps, without waking +//! it; it sees an awake zone, and does not keep it awake (dormant, then +//! hibernated, its socket closed). //! - `fleet`: three celld nodes in production mode on one RustFS bucket //! (docker compose). A zone is reached through a node that does not own //! it, the owner is killed while 200 facts are in flight: the socket @@ -41,11 +46,24 @@ const INTERNAL_SECRET: &str = "test-only-internal-secret-change-me-2026"; const TICKET_SECRET: &str = "test-only-ticket-secret-change-me-2026"; const REQUIRED: &str = "CELLD_ZONE_REQUIRED"; +/// One `celld dev` at a time: two starting together race for the bundler. +static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn serial() -> std::sync::MutexGuard<'static, ()> { + SERIAL + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + fn worker() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/celld/zone-worker") } fn ticket(principal: &str, zone: &str) -> String { + ticket_as(principal, zone, Role::Player) +} + +fn ticket_as(principal: &str, zone: &str, role: Role) -> String { let exp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() @@ -59,7 +77,7 @@ fn ticket(principal: &str, zone: &str) -> String { .as_nanos() ); TicketKey::new(TICKET_SECRET) - .sign(&TicketClaims::new(principal, zone, exp, nonce, Role::Player).with_name(principal)) + .sign(&TicketClaims::new(principal, zone, exp, nonce, role).with_name(principal)) } type Ws = @@ -241,6 +259,7 @@ fn exactly_once(value: &Value, count: u64) -> bool { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn single_node() { + let _serial = serial(); let Some(celld) = celld_support::Celld::start_worker(&worker(), REQUIRED) else { return; }; @@ -317,8 +336,69 @@ async fn single_node() { until(&base, "/tally/ana", long, |v| exactly_once(v, 8)).await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn watchers() { + let _serial = serial(); + let Some(celld) = celld_support::Celld::start_worker(&worker(), REQUIRED) else { + return; + }; + let base = celld.base(); + let long = Duration::from_secs(30); + + // A zone that never ran is not woken by a watcher. + let mut eve = Player::connect(&base, "room-w").await; + eve.hello(&ticket_as("eve", "room-w", Role::Observer)).await; + assert_eq!(eve.refused().await, error::ASLEEP); + let info = get(&base, "/rooms/room-w/info").await; + assert_eq!(info["awake"], false, "{info}"); + assert_eq!(info["wakes"], 0, "{info}"); + + // A player wakes it; a watcher then sees it. + let mut ana = Player::connect(&base, "room-w").await; + ana.hello(&ticket("ana", "room-w")).await; + ana.welcome().await; + let mut eve = Player::connect(&base, "room-w").await; + eve.hello(&ticket_as("eve", "room-w", Role::Observer)).await; + eve.welcome().await; + + // The watcher keeps nothing awake: the player leaves, the zone goes + // dormant and hibernates, and the watcher is let go. + ana.send(ClientMessage::Leave).await; + until(&base, "/zone-activity/room-w", long, |v| { + status(v) == "dormant" + }) + .await; + // A watcher joining the dormant zone is greeted, and it stays dormant. + let mut late = Player::connect(&base, "room-w").await; + late.hello(&ticket_as("late", "room-w", Role::Observer)) + .await; + late.welcome().await; + until(&base, "/zone-activity/room-w", long, |v| { + status(v) == "hibernated" + }) + .await; + let closed = eve + .expect(long, "the watcher let go", |got| match got { + Got::Closed(code) => Some(*code), + _ => None, + }) + .await; + assert_eq!(closed, Some(1000)); + let info = get(&base, "/rooms/room-w/info").await; + assert_eq!(info["awake"], false, "{info}"); + + // Asleep: a watcher is refused again, the zone stays asleep. + let mut eve = Player::connect(&base, "room-w").await; + eve.hello(&ticket_as("eve", "room-w", Role::Observer)).await; + assert_eq!(eve.refused().await, error::ASLEEP); + let info = get(&base, "/rooms/room-w/info").await; + assert_eq!(info["awake"], false, "{info}"); + assert_eq!(info["wakes"], 1, "woken once, by the player: {info}"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fleet() { + let _serial = serial(); let Some(mut fleet) = fleet::Fleet::start(&worker(), 3, &[("CELLD_ZONE_NODES", "{nodes}")], REQUIRED) else { diff --git a/js/README.md b/js/README.md index 3a09c189..a2a0dfab 100644 --- a/js/README.md +++ b/js/README.md @@ -771,7 +771,11 @@ requestAnimationFrame(function frame(now) { }); ``` -`render()` merges the authoritative zone and observed neighbours (`observe`) +`render()` merges the authoritative zone and observed neighbours (`observe`; +`watchZones(game, around)` keeps the zones `around(authority)` observed +across handoffs, lets go of closed ones and retries them every 3 s — zones +hosted as celld objects treat observers as passive watchers that never wake +a sleeping zone, which refuses them with `GameErrorCode.ASLEEP`) sampled at `serverNow − interpolationDelay`, with the controlled entity at its predicted state. The delay is **adaptive** per zone by default (`AdaptiveDelay`: `rtt / 2 + 2 patch intervals + 2 × jitter`, clamped to diff --git a/js/src/game/index.ts b/js/src/game/index.ts index 05e9b39e..1bb9b53b 100644 --- a/js/src/game/index.ts +++ b/js/src/game/index.ts @@ -7,6 +7,8 @@ * - `Predictor`: client prediction + per-field reconciliation policies. * - `GameClient`: authority + observer zones, merged render, handoff, * adaptive interpolation delay, runtime prediction toggle. + * - `watchZones`: observe the zones around the authority (neighbouring + * rooms), letting go of sleeping ones and retrying them. * - `AdaptiveDelay` / `InputPacer`: network-following render delay and * one-input-per-tick pacing for render loops. * - Typed payload codec (`encodePayload`, …) and helpers for clients @@ -168,6 +170,7 @@ export { type DelaySource, type InputPacerOptions } from './pacing.js'; +export { watchZones, type WatchState, type WatchZonesOptions, type ZoneWatch } from './watch.js'; export { GameClient, type ControlledEntityResolver, diff --git a/js/src/game/messages.ts b/js/src/game/messages.ts index b414bceb..03bcbfe9 100644 --- a/js/src/game/messages.ts +++ b/js/src/game/messages.ts @@ -63,7 +63,9 @@ export const GameErrorCode = Object.freeze({ NOT_FOUND: 7, RECONNECT_FAILED: 8, /** The zone could not be created right now (factory or allocation failure); retry later. */ - ZONE_UNAVAILABLE: 9 + ZONE_UNAVAILABLE: 9, + /** The zone is asleep and an observer does not wake it (celld zones: observers are passive watchers); try later. */ + ASLEEP: 10 }); export type HelloMessage = Readonly<{ diff --git a/js/src/game/watch.ts b/js/src/game/watch.ts new file mode 100644 index 00000000..7054a39b --- /dev/null +++ b/js/src/game/watch.ts @@ -0,0 +1,125 @@ +/** + * Keep a set of zones around the authoritative one observed — e.g. the + * neighbouring rooms of a room grid — and merged into `render()`. + * + * Zones hosted as celld objects treat observers as **passive watchers**: a + * watcher never keeps a zone awake and never wakes a sleeping one (it is + * refused with `ASLEEP`, and a zone that falls asleep closes its watchers). + * `watchZones` re-syncs on every authority change, lets go of closed + * watchers (nothing is drawn for a sleeping zone) and tries them again + * after `retryMs`, backing off (doubling up to `maxRetryMs`) while a zone + * stays asleep — every attempt costs an admission ticket. + */ +import type { GameClient } from './client.js'; + +/** Per watched zone: connected, (re)connecting, or refused / closed (asleep). */ +export type WatchState = 'watching' | 'connecting' | 'asleep'; + +export type WatchZonesOptions = Readonly<{ + /** First retry of a zone that is not watched (default 3000 ms). */ + retryMs?: number; + /** Longest retry back-off (default 30000 ms). */ + maxRetryMs?: number; + /** Clock (ms; tests). */ + now?: () => number; + /** Timer functions (tests). */ + setInterval?: (fn: () => void, ms: number) => unknown; + clearInterval?: (handle: unknown) => void; +}>; + +export type ZoneWatch = Readonly<{ + /** State of every wanted zone. */ + states(): ReadonlyMap; + /** Re-sync now (also runs on authority changes and every `retryMs`). */ + sync(): void; + /** Stop syncing (observers already open stay until `unobserve`/`close`). */ + stop(): void; +}>; + +/** + * Observe `around(authorityZone)` (the authoritative zone itself is skipped) + * for as long as the watch runs. + */ +export function watchZones( + client: GameClient, + around: (authorityZone: string) => readonly string[], + options: WatchZonesOptions = {} +): ZoneWatch { + const states = new Map(); + /** Per zone: failed attempts in a row and when to try again. */ + const backoff = new Map(); + const now = options.now ?? (() => Date.now()); + const retryMs = options.retryMs ?? 3000; + const maxRetryMs = options.maxRetryMs ?? 30_000; + const failed = (zone: string) => { + const failures = (backoff.get(zone)?.failures ?? 0) + 1; + backoff.set(zone, { failures, at: now() + Math.min(maxRetryMs, retryMs * 2 ** (failures - 1)) }); + }; + let wanted = new Set(); + let stopped = false; + const letGo = (zone: string) => { + if (client.zone(zone)?.role === 'observer') client.unobserve(zone); + }; + const sync = () => { + if (stopped) return; + const here = client.authority?.zoneId; + wanted = new Set(here === undefined ? [] : around(here).filter((z) => z !== here)); + for (const zone of client.zones()) { + if (zone.role === 'observer' && !wanted.has(zone.zoneId)) client.unobserve(zone.zoneId); + } + for (const zone of [...states.keys()]) if (!wanted.has(zone)) states.delete(zone); + for (const zone of [...backoff.keys()]) if (!wanted.has(zone)) backoff.delete(zone); + for (const zone of wanted) { + const existing = client.zone(zone); + if (existing?.role === 'authority') continue; + if (existing?.role === 'observer' && existing.connection.state !== 'closed') continue; + // A closed watcher (refused or let go by a sleeping zone): anew, + // once its back-off is over. + if ((backoff.get(zone)?.at ?? 0) > now()) continue; + letGo(zone); + if (states.get(zone) !== 'asleep') states.set(zone, 'connecting'); + let welcomed: Promise; + try { + welcomed = client.observe(zone); + } catch { + return; // the client closed + } + welcomed.then( + () => { + backoff.delete(zone); + if (wanted.has(zone)) states.set(zone, 'watching'); + }, + () => { + failed(zone); + if (wanted.has(zone)) states.set(zone, 'asleep'); + // Nothing to show of a sleeping zone. + letGo(zone); + } + ); + } + }; + const offs = [ + client.on('authority', () => sync()), + client.on('zoneClosed', (zone) => { + if (!wanted.has(zone) || client.zone(zone)?.role !== 'observer') return; + failed(zone); + states.set(zone, 'asleep'); + letGo(zone); + }) + ]; + const every = options.setInterval ?? ((fn: () => void, ms: number) => globalThis.setInterval(fn, ms)); + const clear = options.clearInterval ?? ((handle: unknown) => globalThis.clearInterval(handle as ReturnType)); + const timer = every(sync, Math.min(1000, retryMs)); + // Never keep a Node process alive for this. + (timer as { unref?: () => void } | undefined)?.unref?.(); + sync(); + return { + states: () => states, + sync, + stop: () => { + stopped = true; + clear(timer); + for (const off of offs) off(); + } + }; +} diff --git a/js/tests/game-watch.test.mjs b/js/tests/game-watch.test.mjs new file mode 100644 index 00000000..ab1cb0ab --- /dev/null +++ b/js/tests/game-watch.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { watchZones } from '../dist/game/index.js'; + +/** A GameClient stand-in: zones, observe / unobserve, events. */ +function stubClient() { + const zones = new Map(); + const handlers = new Map(); + const answers = new Map(); // zone → 'ok' | 'asleep' + const client = { + authority: undefined, + observed: [], + zones: () => [...zones.values()], + zone: (id) => zones.get(id), + observe(id) { + client.observed.push(id); + const zone = { zoneId: id, role: 'observer', connection: { state: 'connecting' } }; + zones.set(id, zone); + if ((answers.get(id) ?? 'asleep') === 'ok') { + zone.connection.state = 'open'; + return Promise.resolve({}); + } + zone.connection.state = 'closed'; + return Promise.reject(new Error('ASLEEP')); + }, + unobserve(id) { + if (zones.get(id)?.role === 'observer') zones.delete(id); + }, + on(type, fn) { + handlers.set(type, [...(handlers.get(type) ?? []), fn]); + return () => handlers.set(type, (handlers.get(type) ?? []).filter((h) => h !== fn)); + }, + emit(type, ...args) { + for (const h of handlers.get(type) ?? []) h(...args); + }, + join(id) { + client.authority = { zoneId: id }; + zones.set(id, { zoneId: id, role: 'authority', connection: { state: 'open' } }); + client.emit('authority', id); + }, + answers + }; + return client; +} + +const flush = () => new Promise((r) => setImmediate(r)); + +test('watchZones observes the zones around the authority, lets sleeping ones go and retries', async () => { + const client = stubClient(); + let tick = () => {}; + const around = (zone) => { + const n = Number(zone.slice(1)); + return [`z${n - 1}`, `z${n}`, `z${n + 1}`]; + }; + let clock = 0; + const watch = watchZones(client, around, { now: () => clock, setInterval: (fn) => ((tick = fn), 1), clearInterval: () => {} }); + assert.equal(watch.states().size, 0, 'no authority yet'); + client.answers.set('z1', 'ok'); + client.join('z2'); + await flush(); + assert.deepEqual(Object.fromEntries(watch.states()), { z1: 'watching', z3: 'asleep' }); + assert.equal(client.zone('z3'), undefined, 'a refused watcher is let go (nothing drawn)'); + assert.equal(client.zone('z2').role, 'authority', 'the own zone is not observed'); + // z3 wakes up later: the retry (after its back-off) finds it. + client.answers.set('z3', 'ok'); + const tries = client.observed.length; + tick(); + assert.equal(client.observed.length, tries, 'still backing off'); + clock += 3000; + tick(); + await flush(); + assert.equal(watch.states().get('z3'), 'watching'); + // z1 falls asleep: its watcher closes and is let go. + client.zone('z1').connection.state = 'closed'; + client.emit('zoneClosed', 'z1', { reason: 'closed' }); + assert.equal(watch.states().get('z1'), 'asleep'); + assert.equal(client.zone('z1'), undefined); + // Moving on: zones no longer around are unobserved. + client.join('z5'); + await flush(); + assert.deepEqual([...watch.states().keys()].sort(), ['z4', 'z6']); + assert.equal(client.zone('z3'), undefined); + watch.stop(); + const before = client.observed.length; + client.join('z9'); + tick(); + assert.equal(client.observed.length, before, 'stopped'); +}); From 7c3cd78bb473fa5a32ed989e8c3604f8d29deedd Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 21 Oct 2026 00:16:11 -0500 Subject: [PATCH 04/21] feat(game)!: Mob Pit sees next door, delete the Endless Grid Mob Pit watches the 8 rooms around the player's own (Vite and SvelteKit, `watchZones`): their mobs are drawn where they are, a sleeping room is not woken and shows nothing, and a watched room still goes dormant and hibernates when its last player leaves. The room gives a watcher the whole room; the HUD counts rooms watched and asleep, the minimap outlines the watched ones; the leaderboard page's entrance watcher retries while the entrance sleeps. The SvelteKit ticket handler allows the observer tickets this needs. The Endless Grid demo is deleted (zone, dev map routes, page, generated client, tests, run.sh entry, README). `game_sim::grid` keeps only the seeded noise, biomes and props Mob Pit's rooms use. Tests: mobpit-neighbours-headless; the SvelteKit test checks a player sees the mobs next door while the watched entrance still hibernates. Refs [[tasks/gr-p44-mobpit-world]] --- examples/game/README.md | 120 +-- examples/game/client/grid/index.html | 13 - examples/game/client/index.html | 1 - .../game/client/src/generated/grid/client.ts | 68 -- .../client/src/generated/grid/constants.ts | 17 - .../game/client/src/generated/grid/events.ts | 134 ---- .../game/client/src/generated/grid/index.ts | 10 - .../game/client/src/generated/grid/input.ts | 52 -- .../game/client/src/generated/grid/predict.ts | 52 -- .../game/client/src/generated/grid/pures.ts | 88 --- .../game/client/src/generated/grid/schema.ts | 129 --- .../client/src/generated/grid/surface.json | 596 -------------- examples/game/client/src/grid/client.ts | 242 ------ examples/game/client/src/grid/grid.css | 71 -- examples/game/client/src/grid/main.ts | 488 ------------ examples/game/client/src/leaderboard/main.ts | 19 +- examples/game/client/src/mobpit/client.ts | 42 +- examples/game/client/src/mobpit/main.ts | 12 +- examples/game/client/src/mobpit/style.css | 3 + .../game/client/tests/grid-headless.test.mjs | 119 --- .../tests/mobpit-neighbours-headless.test.mjs | 70 ++ .../client/tests/prediction-wasm.test.mjs | 74 +- examples/game/client/vite.config.ts | 2 +- examples/game/run.sh | 10 +- examples/game/server/rooms/src/zone.rs | 6 +- examples/game/server/sim/src/grid.rs | 350 +-------- examples/game/server/sim/src/lib.rs | 12 +- examples/game/server/src/grid/dev.rs | 211 ----- examples/game/server/src/grid/mod.rs | 742 ------------------ examples/game/server/src/lib.rs | 1 - examples/game/server/src/main.rs | 4 - examples/game/server/src/predict_trace.rs | 182 +---- examples/game/server/tests/grid_smoke.rs | 310 -------- examples/game/sveltekit/src/lib/pit/loop.ts | 15 + .../game/sveltekit/src/lib/server/game.ts | 3 + .../sveltekit/src/routes/pit/+page.svelte | 31 +- examples/game/sveltekit/tests/pit.test.mjs | 9 +- 37 files changed, 274 insertions(+), 4034 deletions(-) delete mode 100644 examples/game/client/grid/index.html delete mode 100644 examples/game/client/src/generated/grid/client.ts delete mode 100644 examples/game/client/src/generated/grid/constants.ts delete mode 100644 examples/game/client/src/generated/grid/events.ts delete mode 100644 examples/game/client/src/generated/grid/index.ts delete mode 100644 examples/game/client/src/generated/grid/input.ts delete mode 100644 examples/game/client/src/generated/grid/predict.ts delete mode 100644 examples/game/client/src/generated/grid/pures.ts delete mode 100644 examples/game/client/src/generated/grid/schema.ts delete mode 100644 examples/game/client/src/generated/grid/surface.json delete mode 100644 examples/game/client/src/grid/client.ts delete mode 100644 examples/game/client/src/grid/grid.css delete mode 100644 examples/game/client/src/grid/main.ts delete mode 100644 examples/game/client/tests/grid-headless.test.mjs create mode 100644 examples/game/client/tests/mobpit-neighbours-headless.test.mjs delete mode 100644 examples/game/server/src/grid/dev.rs delete mode 100644 examples/game/server/src/grid/mod.rs delete mode 100644 examples/game/server/tests/grid_smoke.rs diff --git a/examples/game/README.md b/examples/game/README.md index 35249c3e..f1991e8b 100644 --- a/examples/game/README.md +++ b/examples/game/README.md @@ -8,11 +8,10 @@ guests and lets any page change the network simulation. | Demo | Run | Mechanic it demonstrates | Durable state | |---|---|---|---| -| **Mob Pit** | `./run.sh mobpit` (`--fleet` for a local multi-node celld) | A near-infinite seeded grid of rooms joined by doorways, **each room a celld Durable Object** (celld places, forwards, hibernates and recovers rooms; the debug overlay shows the node); authoritative melee combat vs. telegraphing mobs; a **network panel** (latency / jitter / loss applied by every room object on celld, client prediction of the Rust step as WASM with reconciliation, a server ghost, an interest ring: what is outside is not replicated); kills are **non-blocking durable facts** settled exactly once in per-player cells; rooms you leave go dormant then asleep (live minimap from the zone status read model) and come back with their mobs | celld: `pit..` rooms (snapshots), `crawler/{player}`, `presence/{player}`, `zone-activity/{room}`; crawler list and room status = read models projected from the cells' events | +| **Mob Pit** | `./run.sh mobpit` (`--fleet` for a local multi-node celld) | A near-infinite seeded grid of rooms joined by doorways, **each room a celld Durable Object** (celld places, forwards, hibernates and recovers rooms; the debug overlay shows the node); authoritative melee combat vs. telegraphing mobs; the **rooms next door** watched as passive observers (their mobs are drawn; a watcher never wakes a sleeping room or keeps one awake); a **network panel** (latency / jitter / loss applied by every room object on celld, client prediction of the Rust step as WASM with reconciliation, a server ghost, an interest ring: what is outside is not replicated); kills are **non-blocking durable facts** settled exactly once in per-player cells; rooms you leave go dormant then asleep (live minimap from the zone status read model) and come back with their mobs | celld: `pit..` rooms (snapshots), `crawler/{player}`, `presence/{player}`, `zone-activity/{room}`; crawler list and room status = read models projected from the cells' events | | **Leaderboard** | `./run.sh leaderboard` | Same setup as Mob Pit: kills/XP sorted-set boards (in-memory or Redis) fed from the projected crawler list with idempotent `max` updates, your rank + neighbours, rebuild from the cells' event history | celld (the Mob Pit cells); boards are a projection (memory or Redis) | | **Shooting Gallery** | `./run.sh gallery` | Lag compensation: hitscan shots resolved with `ctx.rewind().hit_test_shot` at the shooter's view time (`client_tick` + interpolation delay in the payload), `max_rewind` clamp, per-shot compensation on/off, predicted vs confirmed hits | none (stateless: score is per session) | | **The Freeze** | `./run.sh freeze` | World-version barrier (`WorldCoordinator::rollout`) across **two server processes** as an in-world "admin update": freeze → apply → resume with change notes, inputs dropped while frozen, Hard reload vs safe-room gating (`StalePolicy::SafeRoom`, `Zone::is_gate`) of old clients, aborted rollout (`freeze_ready` → `NotAcked`), facts/events stamped with the content version | celld: `gem-pouch/{player}`, `content-log/{zone}` | -| **Endless Grid** | `./run.sh grid` | Seeded procedural world (terrain/biomes/props generated bit-identically in Rust and TS, nothing static replicated), 32 m cells as zones, observer connections to the 8 neighbours, handoff across borders **and hosts** (2 processes, hash ring, Redirect + transfer tickets), hibernation to celld and restore | celld: `zone-lease/{zone}`, `zone-state/{zone}` (hibernation) | | **Mob Pit · SvelteKit** | `REDIS_URL=… ./run.sh sveltekit` | The same Mob Pit server behind a SvelteKit app: Auth.js session sign-in, admission tickets minted from the session (`createGameTicketHandler`), the game client and WASM step generated by the Vite plugin (`games`), a runes HUD (`useGame`) next to a plain canvas loop, the settled kills as a business-side read model (Crawler domain events projected by `mobpit-service` into SQL, read through generated `@load @live` GraphQL), and the zone server's Redis XP board through GraphQL board roots, live across processes over the bus. See [SvelteKit](#sveltekit) | celld (the Mob Pit cells) + `mobpit-service`'s SQL read model + the Redis boards | | **Boss Ripple** | `./run.sh ripple` | Zone hierarchy realm › city › borough › hood (`DottedTopology`), per-tier tick rates (2/5/10/30 Hz), boss defeats propagating up as tier-up events (each hop on another host), parent broadcasts reaching players through observer connections | celld: `zone-lease/{zone}`, `zone-state/{zone}` (hibernation) | @@ -29,13 +28,12 @@ for the kinds (`mobpit::zone::PlayerView`, `mobpit::zone::MobView`, …), command inputs they emit as facts (`mobpit::crawler::RecordKillInput`). The movement that clients predict is declared once with `#[predict]` in the `game_sim` crate (`server/sim/`, no server dependencies): -`arena_step` (The Freeze), `pit_step` (Mob Pit), `hood_step` (Boss -Ripple) and `grid_step` (Endless Grid, whose context is the world seed and -cell); the zones call those functions in `on_tick`. Each demo module +`arena_step` (The Freeze), `pit_step` (Mob Pit) and `hood_step` (Boss +Ripple); the zones call those functions in `on_tick`. Each demo module exports `game_surface()`; `run.sh gen` runs `distributed game-manifest` on it, `distributed game-client` writes `client/src/generated//` (checked in, verified by `run.sh smoke`) and `distributed game-wasm` -compiles the step — and the grid's `#[game_pure]` world generation — to +compiles the step — and Mob Pit's `#[game_pure]` room generation — to `client/src/generated//wasm/.wasm` (gitignored, rebuilt only when the Rust sources change; `run.sh ` builds it too). The pages use the generated `createClient`, which predicts with the compiled step, @@ -43,11 +41,11 @@ typed field accessors (`mobFields(entity).hp`), the input encoder and typed `on('kill', …)` handlers. No simulation or content code is written twice. Movement is the compiled -step; the Endless Grid's terrain, biomes and props come from the compiled -`grid_cell` / `grid_sample` (`createGridPures`); static data the pages +step; Mob Pit's rooms (biome, danger, mobs) come from the compiled +`pit_room` (`createMobpitPures`); static data the pages draw is a generated constant from the Rust values (`PIT_WORLD` — room size, doorways, pillars, capsule radius —, `ARENA_FLOOR` for The Freeze and -Boss Ripple, `GRID_WORLD`, `FREEZE_WORLD`, `PIT_RULES` tier radii, `RIPPLE_WORLD` zone +Boss Ripple, `FREEZE_WORLD`, `PIT_RULES` tier radii, `RIPPLE_WORLD` zone hierarchy, `GALLERY_COURSE` rails); The Freeze's content packs are a Rust record served in its payload encoding and decoded by the generated `decodePack`. What TypeScript still mirrors is small and listed under @@ -144,73 +142,6 @@ their stamp. Display names live on the primary host: a player joining Ember Fields directly (`&zone=freeze-ember`) shows as a guest there, names carry over portals. -## Endless Grid - -```sh -examples/game/run.sh grid -# → http://localhost:5173/grid/ (?zone=grid.4.-2&name=Ana optional) -``` - -Two `game-demos grid` processes: host **A** on `GAME_DEMOS_PORT` (Vite -proxies to it) and **B** on the next port, each listing the other with -`--peer`, sharing one ticket secret (the admin secret for host-to-host calls -is derived from it) and one celld (`run.sh` starts `celld dev` with -`cells/`; `--celld-url` / `CELLD_URL` and `DISTRIBUTED_INTERNAL_SECRET` are -required, there is no local fallback): - -- **Ownership**: every zone claim is a celld `ZoneLease:{zone}` cell - (`CellZoneOwnership`); a host that stops renewing gets a - `zone.lease_expired` from the cell's alarm. -- **Hibernation**: every snapshot is a celld `ZoneState:{zone}` cell - (`CellZoneStateStore`), fenced by the lease token; a sleeping zone is a - sleeping Durable Object. Snapshots above 64 KiB go to celld's object - storage (R2 binding `ZONE_SNAPSHOTS`), the cell keeps their digest. -- **Zone status**: both cells publish their transitions (`zone.activated` - / `zone.released` / `zone.lease_expired`, `zone_state.saved` / - `zone_state.deleted`, each carrying the cell's small status, never the - snapshot bytes). celld's outbox Queue relays them to host A's - `/internal/celld-queue/relay`, which publishes onto the world's shared - projection bus (`--data-dir`, `run.sh` uses `.data/`); every host - projects the bus into its own zone status read model - (`distributed_game::world::zone_status`, `common/zone_status.rs`). - -The cluster smoke tests (`tests/grid_smoke.rs`, `tests/ripple_smoke.rs`) -and the headless tests start their own `celld dev` the same way. - -- **World.** Cell `(x, y)` is zone `grid..` (canonical integers, - |x|,|y| ≤ 10⁶; the server uses `FlatTopology`, the dots are not a - hierarchy). Its terrain height, biome and props are a pure function of - `(seed, x, y, CONTENT_VERSION)` in `server/sim/src/grid.rs` (u32 hashing - + binary32 value noise). The page renders the same code compiled to - WebAssembly: `grid_cell` returns a cell's 17 × 17 terrain lattice - (heights, moisture, biomes) and props, `grid_sample` one point (entity - heights, the HUD biome); generating a cell costs ~13 µs in Node including - decoding (~6 µs in the module; the removed TypeScript twin took ~77 µs). - Props sit inside 8 m - sub-cells 2 m from their edges, so lines every 8 m are free trails and no - prop reaches across a border. Seed: `DG_GRID_SEED`. -- **Zones.** A cell simulates walkers (predicted by `grid_step` compiled to - WebAssembly, which generates and collides with the cell's props itself; - the client sets its context from the hello's seed and the authoritative - cell) and 2–4 critters. Past a border by - 0.5 m the walker is handed off with its position in the neighbour's - frame (`ctx.handoff`); the SDK follows the Handoff — through a Redirect - when the neighbour lives on the other host (`HashRing`, 64 vnodes). -- **Interest across borders.** The client observes the 8 neighbours and - re-syncs on every authority change; entities are drawn at - `cell × 32 + local` around a floating origin. -- **Hibernation.** Nobody inside or observing → Idle → Dormant after 2 s → - hibernated after 4 s more (`--hibernate-after-ms`): critters and markers - (E) go to the zone's celld `ZoneState` cell and come back on the next - activation (on either host); its hello says - `restored` and the page toasts. The minimap (`GET /dev/grid/map`) shows - active / hibernated / cold cells per host from the zone status read model - (eventually consistent; it never opens a cell, since reading a never-used - celld cell creates it), with live tier and player counts asked from the - host running each active cell; borders are tinted by owning host. - -Controls: WASD / stick, Space/Shift sprint, E drops a marker. - ## Boss Ripple ```sh @@ -387,15 +318,15 @@ each step, so the client rebases on exactly what the server holds. `game-demos predict-trace` (recorded input streams per step function, and recorded arguments per pure function) through every surface's module and requires byte-identical states and results; it also times prediction -(≈ 1 µs per step, ≈ 8–12 µs for a reconciliation replaying 10 inputs) and -grid generation (≈ 13 µs per cell, ≈ 0.5 µs per point sample). +(≈ 1–3 µs per step, ≈ 10–30 µs for a reconciliation replaying 10 inputs) +and room generation. ## Remaining client-side mirrors What the TypeScript still restates, and why it stays: -- **Zone id formats and defaults** — `grid..` (`zoneId` / - `parseZone` in `grid/client.ts`) and each page's default zone +- **Zone id formats and defaults** — `pit..` (`parseRoom` / + `roomZone` in `mobpit/client.ts`) and each page's default zone (`pit.0.0`, `freeze-hub`, …). They are addressing, needed synchronously before any module loads; a mismatch fails loudly (the server rejects the zone). - **Dev / admin HTTP JSON shapes** — `WorldMap`, `WorldStatus`, @@ -431,7 +362,7 @@ examples/game/ live push, board push over bus notices, no mutations, 401) sim/ crate `game_sim`: the `#[predict]` steps (arena.rs movement + floor, mobpit.rs (+ `#[game_pure]` pit_room, doorways), ripple.rs inputs + steps, - grid.rs world + walker step + `#[game_pure]` grid_cell / grid_sample); compiled natively for the + grid.rs seeded noise, biomes and props); compiled natively for the server and to wasm32 for the browser src/main.rs `game-demos ` subcommands (+ hidden `predict-trace`) src/predict_trace.rs native traces of every step (recorded inputs) and pure function (arguments) @@ -445,10 +376,9 @@ examples/game/ crawler directory and room status projections, leaderboards src/gallery/ Shooting Gallery zone (targets, rewind hit tests) src/freeze/ The Freeze zone, content packs, two-host world + admin routes - src/grid/ Endless Grid zone (`world` = `game_sim::grid`), dev map routes src/ripple/ Boss Ripple tiers and placement tests/ mobpit_smoke.rs (facts, rooms on celld, celld crash, boards, presence), - gallery_smoke.rs, freeze_smoke.rs (binary content packs), grid_smoke.rs, + gallery_smoke.rs, freeze_smoke.rs (binary content packs), ripple_smoke.rs (+ support/ for two in-process hosts) sveltekit/ SvelteKit app over the Mob Pit server + mobpit-service (see SvelteKit above) distributed.config.js game surface (`games`) + GraphQL client (`clients`) for the Vite plugin @@ -466,7 +396,7 @@ examples/game/ client/ Vite + TypeScript + Babylon.js 9.30 (multi-page) index.html, /index.html src/generated// generated by `run.sh gen` (do not edit): schema/input/events/client.ts, - predict.ts, pures.ts (grid), constants.ts, surface.json, + predict.ts, pures.ts (mobpit), constants.ts, surface.json, wasm/.wasm (gitignored build output); `mobpit` also serves the leaderboard page src/shared/ hud.ts, input.ts, scene.ts, dev-api.ts, latest.ts, style.css @@ -474,11 +404,11 @@ examples/game/ src/leaderboard/ main.ts (DOM page over the observer connection), style.css src/gallery/ client.ts (shots, adaptive delay), main.ts src/freeze/ client.ts (content packs, live patch / reload, admin API), main.ts - src/grid/, src/ripple/ client.ts (headless; grid: compiled world content), main.ts (Babylon) + src/ripple/ client.ts (headless), main.ts (Babylon) tests/ prediction-wasm (native ↔ WASM exact match of steps + pure functions, cost), mobpit-headless, mobpit-network-headless (NetSim on celld rooms, exact prediction, interest), mobpit-restarts-headless, leaderboard-headless, - gallery-headless, freeze-headless, grid-headless, + mobpit-neighbours-headless (passive watchers), gallery-headless, freeze-headless, ripple-headless (+ helpers/demo-server.mjs, helpers/mobpit.mjs, helpers/cluster.mjs for multi-host demos, helpers/celld.mjs for a per-test `celld dev`) @@ -532,7 +462,7 @@ it survives the room sleeping, the services restarting and celld restarting (the last periodic snapshot). XP, level and kills come from your Crawler cell. Tested by `client/tests/mobpit-restarts-headless.test.mjs`. -**Rooms.** What a room holds — its biome (the Endless Grid's seeded noise), +**Rooms.** What a room holds — its biome (the seeded noise of `game_sim::grid`), danger (grows with the distance from `pit.0.0`) and 6–12 mobs with their tiers and homes — is `game_sim::mobpit::pit_room(seed, x, y)`, a `#[game_pure]` function: the zone builds the room from it, the page runs @@ -551,8 +481,15 @@ handoff (`prediction.handoffFrame`: the room offset), sends the inputs of the transfer to the new room once it is in, and eases into the new room's state, so the handoff's latency never shows. Positions on the wire stay room-local; the page adds the room's offset and keeps a floating origin at -your room. Mobs of neighbouring rooms are not shown yet: observing a room -keeps it awake, which would defeat room hibernation. A room that empties writes its snapshot +your room. **The rooms next door are watched**: the page observes the 8 +rooms around yours (`watchZones` in the SDK, re-synced on every doorway) +and draws their mobs where they are (a watcher sees the whole room). On +celld an observer is a **passive watcher**: it never counts as occupancy +(a room with only watchers stops ticking, goes dormant and hibernates, and +lets its watchers go) and never wakes a sleeping room (its Hello is +refused with `ASLEEP`; the page retries every 3 s and shows nothing +meanwhile — the HUD counts rooms watched and asleep, the minimap outlines +the watched ones). A room that empties writes its snapshot (`Zone::hibernate`: every mob's position, HP, state, generation and respawn timer, and the room's kill count) and goes **dormant**; after `ZONE_HIBERNATE_AFTER_MS` (10 s) it is **hibernated**: dropped from memory, @@ -626,7 +563,7 @@ fleet test) kills the owner node of a room with 200 facts in flight and counts exactly 200 in the cell. **celld.** Every demo with durable state runs on celld: -`run.sh mobpit|leaderboard|freeze|grid|ripple|sveltekit|smoke` need the celld +`run.sh mobpit|leaderboard|freeze|ripple|sveltekit|smoke` need the celld version pinned in the repository's `.celld-version` (`CELLD_BIN`, default `celld` on PATH; `scripts/celld.sh check` verifies it and prints the install command) and `worker-build` (`cargo install worker-build --locked`) for @@ -659,7 +596,8 @@ are available. (`identicalTop`). - **Page**: polls the snapshot (`GET /dev/mobpit/leaderboard`), reads your rank and neighbours on each change (`?member=…`), and observes the - entrance room (no avatar) for a live mini map. + entrance room (no avatar) for a live mini map (a passive watcher: the + map stays empty while the entrance sleeps). Dev routes (`--dev` only, none takes an identity or a reward): `POST /dev/ticket`, `GET /dev/mobpit/rooms` (room status), `GET diff --git a/examples/game/client/grid/index.html b/examples/game/client/grid/index.html deleted file mode 100644 index baf66d5b..00000000 --- a/examples/game/client/grid/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Endless Grid Demo - - - -
    - - - diff --git a/examples/game/client/index.html b/examples/game/client/index.html index b8dcef68..0be41463 100644 --- a/examples/game/client/index.html +++ b/examples/game/client/index.html @@ -15,7 +15,6 @@

    distributed_game demos

  • Leaderboard — kills/XP sorted-set boards projected from the cells, pushed live, rebuilt from events (runs with Mob Pit).
  • Shooting Gallery — lag compensation: hitscan shots rewound to the shooter's view time, max-rewind clamp, compensation on/off.
  • The Freeze — world-version barrier across two server processes as a story beat: hard reload, safe-room gating of old clients, aborted rollout.
  • -
  • Endless Grid — seeded procedural cells as zones, observer connections across borders, handoff across two hosts, hibernation.
  • Boss Ripple — realm › city › borough › hood, per-tier tick rates, boss defeats rippling up as tier-up events across hosts.
diff --git a/examples/game/client/src/generated/grid/client.ts b/examples/game/client/src/generated/grid/client.ts deleted file mode 100644 index 325a2916..00000000 --- a/examples/game/client/src/generated/grid/client.ts +++ /dev/null @@ -1,68 +0,0 @@ -// @generated by `distributed game-client` from game surface `grid` (schema v3, protocol v1). -// Typed client factory. -// Do not edit: change the Rust definitions and regenerate. -import { GameClient, mergeCorrections, type GameClientOptions, type GamePredictionOptions, type WasmSource } from '@hops-ops/distributed/game'; - -import { EVENTS, type Events } from './events.ts'; -import { encodeInput, type MoveInput } from './input.ts'; -import { createGridPrediction, type GridPrediction } from './predict.ts'; -import { CORRECTIONS } from './schema.ts'; - -/** - * Prediction tuning; `encodeInput`, the correction defaults and `predict` (the - * compiled `#[predict]` step) are filled in. A `predict` given here replaces - * the WebAssembly step. - */ -export type GridPredictionOptions = Omit, 'encodeInput' | 'predict'> & - Partial, 'encodeInput' | 'predict'>>; - -export type GridClientOptions = Omit, 'prediction'> & - Readonly<{ - /** Prediction tuning, or `false` for none. Default: the compiled step. */ - prediction?: GridPredictionOptions | false; - /** Where to load the compiled step from (default `PREDICT_WASM_URL`). */ - wasm?: WasmSource; - }>; - -export type GridClient = Readonly<{ - client: GameClient; - /** The compiled step (`ready`, `setContext`); undefined when prediction is off or custom. */ - prediction: GridPrediction | undefined; - /** Typed event subscription (every zone); returns the unsubscribe. */ - on(name: K, handler: (value: Events[K], zoneId: string) => void): () => void; - /** Send one input: predicted when prediction is on, raw otherwise. */ - send(input: MoveInput): number | undefined; -}>; - -/** - * A `GameClient` for the `grid` surface with its input encoder, correction - * defaults and the compiled predicted step wired in. - */ -export function createGridClient(options: GridClientOptions): GridClient { - const { prediction: tuning, wasm, ...rest } = options; - const prediction = - tuning === false || tuning?.predict !== undefined ? undefined : createGridPrediction(wasm); - const client = new GameClient({ - ...rest, - prediction: - tuning === false - ? undefined - : { - ...tuning, - predict: tuning?.predict ?? prediction!.predict, - encodeInput: tuning?.encodeInput ?? ((input) => encodeInput(input)), - corrections: mergeCorrections(CORRECTIONS, tuning?.corrections) - } - }); - return { - client, - prediction, - on: (name, handler) => - client.on('event', (zoneId, event) => { - const codec = EVENTS[name]; - if (event.typeId === codec.id) handler(codec.decode(event.payload), zoneId); - }), - send: (input) => - tuning === false ? client.sendRawInput(encodeInput(input)) : client.sendInput(input) - }; -} diff --git a/examples/game/client/src/generated/grid/constants.ts b/examples/game/client/src/generated/grid/constants.ts deleted file mode 100644 index 8d16e48f..00000000 --- a/examples/game/client/src/generated/grid/constants.ts +++ /dev/null @@ -1,17 +0,0 @@ -// @generated by `distributed game-client` from game surface `grid` (schema v3, protocol v1). -// Constants: static data defined once in Rust (`GameSurface::constant`). -// Do not edit: change the Rust definitions and regenerate. -import type { GridWorld } from './events.ts'; - -/** `grid_world`, as the client decodes it. */ -export const GRID_WORLD: GridWorld = { - cell: 32, - defaultSeed: 12648430, - contentVersion: 1, - samples: 17, - sampleSpacing: 2, - playerRadius: 0.44999998807907104, - maxCell: 1000000, - biomes: ['highlands', 'steppe', 'forest', 'meadow'], - propKinds: ['tree', 'rock', 'bush'] -}; diff --git a/examples/game/client/src/generated/grid/events.ts b/examples/game/client/src/generated/grid/events.ts deleted file mode 100644 index 3df9d235..00000000 --- a/examples/game/client/src/generated/grid/events.ts +++ /dev/null @@ -1,134 +0,0 @@ -// @generated by `distributed game-client` from game surface `grid` (schema v3, protocol v1). -// Events and records: payload interfaces, ids, codecs, typed subscriptions. -// Do not edit: change the Rust definitions and regenerate. -import { decodeGameEvent, decodePayload, encodePayload, onGameEvent, type DecodedGameEvent, type GameEventCodecs, type GameEventSource, type PayloadLayout } from '@hops-ops/distributed/game'; - -/** Record `Prop` (`#[derive(GameRecord)]`). */ -export interface Prop { - readonly kind: number; - readonly x: number; - readonly y: number; - readonly r: number; - readonly solid: boolean; - readonly variant: number; -} - -/** Record `GridCellContent` (`#[derive(GameRecord)]`). */ -export interface GridCellContent { - readonly heights: readonly number[]; - readonly moisture: readonly number[]; - readonly biomes: Uint8Array; - readonly props: readonly Prop[]; -} - -/** Record `GridContext` (`#[derive(GameRecord)]`). */ -export interface GridContext { - readonly seed: number; - readonly cx: number; - readonly cy: number; -} - -/** Record `GridPoint` (`#[derive(GameRecord)]`). */ -export interface GridPoint { - readonly seed: number; - readonly cx: number; - readonly cy: number; - readonly x: number; - readonly y: number; -} - -/** Record `GridSample` (`#[derive(GameRecord)]`). */ -export interface GridSample { - readonly height: number; - readonly moisture: number; - readonly biome: number; -} - -/** Record `GridWorld` (`#[derive(GameRecord)]`). */ -export interface GridWorld { - readonly cell: number; - readonly defaultSeed: number; - readonly contentVersion: number; - readonly samples: number; - readonly sampleSpacing: number; - readonly playerRadius: number; - readonly maxCell: number; - readonly biomes: readonly string[]; - readonly propKinds: readonly string[]; -} - -/** Record `WalkerState` (`#[derive(GameRecord)]`). */ -export interface WalkerState { - readonly x: number; - readonly y: number; -} - -/** Event `hello` (id 1). */ -export interface HelloEvent { - readonly zone: string; - readonly cell: readonly number[]; - readonly host: string; - readonly seed: number; - readonly contentVersion: number; - readonly cellSize: number; - readonly entity: number | undefined; - readonly restored: boolean; - readonly restores: number; - readonly hibernatedForMs: number | undefined; - readonly restoredCritters: number; - readonly restoredMarkers: number; - readonly activatedAt: number; -} - -const HELLO_LAYOUT: PayloadLayout = [ - ['zone', { type: 'short_str' }], - ['cell', { type: 'list', item: { type: 'i32' } }], - ['host', { type: 'short_str' }], - ['seed', { type: 'u32' }], - ['contentVersion', { type: 'u32' }], - ['cellSize', { type: 'f32' }], - ['entity', { type: 'optional', item: { type: 'u32' } }], - ['restored', { type: 'bool' }], - ['restores', { type: 'u32' }], - ['hibernatedForMs', { type: 'optional', item: { type: 'u64' } }], - ['restoredCritters', { type: 'u32' }], - ['restoredMarkers', { type: 'u32' }], - ['activatedAt', { type: 'u64' }], -]; - -/** Event type ids by name. */ -export const EVENT = { - hello: 1, -} as const; - -/** Event payload types by name. */ -export interface Events { - hello: HelloEvent; -} - -/** Codecs of every event. */ -export const EVENTS: GameEventCodecs = { - hello: { - id: 1, - name: 'hello', - encode: (value) => encodePayload(HELLO_LAYOUT, value), - decode: (payload) => decodePayload(HELLO_LAYOUT, payload) as unknown as HelloEvent - }, -}; - -/** A decoded event, discriminated by `name`. */ -export type AnyEvent = DecodedGameEvent; - -/** Decode an Event message (`undefined` for ids this surface does not declare). */ -export function decodeEvent(event: { readonly typeId: number; readonly payload: Uint8Array }): AnyEvent | undefined { - return decodeGameEvent(EVENTS, event); -} - -/** Subscribe to one typed event on every zone of `client`; returns the unsubscribe. */ -export function onEvent( - client: GameEventSource, - name: K, - handler: (value: Events[K], zoneId: string) => void -): () => void { - return onGameEvent(client, EVENTS, name, handler); -} diff --git a/examples/game/client/src/generated/grid/index.ts b/examples/game/client/src/generated/grid/index.ts deleted file mode 100644 index 3b663772..00000000 --- a/examples/game/client/src/generated/grid/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// @generated by `distributed game-client` from game surface `grid` (schema v3, protocol v1). -// Everything generated for this surface. -// Do not edit: change the Rust definitions and regenerate. -export * from './client.ts'; -export * from './constants.ts'; -export * from './events.ts'; -export * from './input.ts'; -export * from './predict.ts'; -export * from './pures.ts'; -export * from './schema.ts'; diff --git a/examples/game/client/src/generated/grid/input.ts b/examples/game/client/src/generated/grid/input.ts deleted file mode 100644 index 6fbbdb41..00000000 --- a/examples/game/client/src/generated/grid/input.ts +++ /dev/null @@ -1,52 +0,0 @@ -// @generated by `distributed game-client` from game surface `grid` (schema v3, protocol v1). -// Input frame: interface, button bits, encoder, decoder, sanitize. -// Do not edit: change the Rust definitions and regenerate. -import { decodePayload, encodePayload, sanitizePayload, type InputRule, type PayloadLayout } from '@hops-ops/distributed/game'; - -/** One `MoveInput` input frame (`#[derive(GameInput)]`). */ -export interface MoveInput { - readonly mx: number; - readonly my: number; - readonly buttons: number; -} - -export type Input = MoveInput; - -/** Bits of each bit-flag field (`#[buttons(..)]`), by field. */ -export const BITS = { - buttons: { - sprint: 1, - mark: 2, - }, -} as const; - -/** Bits of `buttons`, the only bit-flag field. */ -export const BUTTON = BITS.buttons; - -/** Wire layout, in field order. */ -export const INPUT_LAYOUT: PayloadLayout = [ - ['mx', { type: 'i8' }], - ['my', { type: 'i8' }], - ['buttons', { type: 'u8' }], -]; - -const INPUT_RULES: readonly InputRule[] = [ - { range: [-127, 127] }, - { range: [-127, 127] }, - { buttons: 3 }, -]; - -/** Payload bytes of one input frame. */ -export function encodeInput(input: MoveInput): Uint8Array { - return encodePayload(INPUT_LAYOUT, input); -} - -/** Decode one input frame (no sanitizing). */ -export function decodeInput(payload: Uint8Array): MoveInput { - return decodePayload(INPUT_LAYOUT, payload) as unknown as MoveInput; -} - -/** What the zone simulates for `input` (`#[range]` clamps, `#[buttons]` masks, wire precision). */ -export function sanitizeInput(input: MoveInput): MoveInput { - return sanitizePayload(INPUT_LAYOUT, INPUT_RULES, input)!; -} diff --git a/examples/game/client/src/generated/grid/predict.ts b/examples/game/client/src/generated/grid/predict.ts deleted file mode 100644 index 22b33bb5..00000000 --- a/examples/game/client/src/generated/grid/predict.ts +++ /dev/null @@ -1,52 +0,0 @@ -// @generated by `distributed game-client` from game surface `grid` (schema v3, protocol v1). -// Client-predicted step: the Rust `#[predict]` fn compiled to WebAssembly (binary ABI). -// Do not edit: change the Rust definitions and regenerate. -import { createWasmPrediction, type PredictStateLayout, type WasmPrediction, type WasmSource, type PayloadLayout } from '@hops-ops/distributed/game'; - -import type { GridContext } from './events.ts'; -import { encodeInput, type MoveInput } from './input.ts'; - -/** The predicted step: `#[predict]` fn `grid_step` of Cargo package `game_sim`, predicting kind `walker`. */ -export const PREDICT = { - kind: 'walker', - export: 'grid_step', - package: 'game_sim', - state: 'WalkerState', - abi: 1, -} as const; - -/** Predicted `walker` fields (record `WalkerState`): wire field name and codec type, in record order. */ -export const PREDICT_STATE: PredictStateLayout = [ - ['x', { type: 'f32' }], - ['y', { type: 'f32' }], -]; - -/** What the step reads besides state and input (`prediction.setContext`). */ -export type PredictContext = GridContext; - -/** Layout of the context record `GridContext`. */ -export const PREDICT_CONTEXT: PayloadLayout | undefined = [ - ['seed', { type: 'u32' }], - ['cx', { type: 'i32' }], - ['cy', { type: 'i32' }], -]; - -/** The compiled step (`distributed game-wasm`); bundlers emit it as an asset. */ -export const PREDICT_WASM_URL: URL = new URL('./wasm/grid.wasm', import.meta.url); - -/** The `grid` step as `GameClient` prediction (see `createWasmPrediction`). */ -export type GridPrediction = WasmPrediction; - -/** - * Load the compiled step (default `PREDICT_WASM_URL`) as prediction: `predict` - * leaves the state unchanged until `ready` resolves and a context is set. - */ -export function createGridPrediction(source: WasmSource = PREDICT_WASM_URL): GridPrediction { - return createWasmPrediction({ - source, - exportName: PREDICT.export, - state: PREDICT_STATE, - context: PREDICT_CONTEXT, - encodeInput - }); -} diff --git a/examples/game/client/src/generated/grid/pures.ts b/examples/game/client/src/generated/grid/pures.ts deleted file mode 100644 index 79f06eb3..00000000 --- a/examples/game/client/src/generated/grid/pures.ts +++ /dev/null @@ -1,88 +0,0 @@ -// @generated by `distributed game-client` from game surface `grid` (schema v3, protocol v1). -// Pure functions: the Rust `#[game_pure]` fns compiled to WebAssembly (binary ABI). -// Do not edit: change the Rust definitions and regenerate. -import { createWasmPures, type WasmPures, type WasmSource, type WireType } from '@hops-ops/distributed/game'; - -import type { GridCellContent, GridContext, GridPoint, GridSample } from './events.ts'; - -const PROP_TYPE: WireType = { - type: 'record', - fields: [ - ['kind', { type: 'u8' }], - ['x', { type: 'f32' }], - ['y', { type: 'f32' }], - ['r', { type: 'f32' }], - ['solid', { type: 'bool' }], - ['variant', { type: 'u8' }], - ] -}; - -const GRID_CELL_CONTENT_TYPE: WireType = { - type: 'record', - fields: [ - ['heights', { type: 'list', item: { type: 'f32' } }], - ['moisture', { type: 'list', item: { type: 'f32' } }], - ['biomes', { type: 'bytes' }], - ['props', { type: 'list', item: PROP_TYPE }], - ] -}; - -const GRID_CONTEXT_TYPE: WireType = { - type: 'record', - fields: [ - ['seed', { type: 'u32' }], - ['cx', { type: 'i32' }], - ['cy', { type: 'i32' }], - ] -}; - -const GRID_POINT_TYPE: WireType = { - type: 'record', - fields: [ - ['seed', { type: 'u32' }], - ['cx', { type: 'i32' }], - ['cy', { type: 'i32' }], - ['x', { type: 'f32' }], - ['y', { type: 'f32' }], - ] -}; - -const GRID_SAMPLE_TYPE: WireType = { - type: 'record', - fields: [ - ['height', { type: 'f32' }], - ['moisture', { type: 'f32' }], - ['biome', { type: 'u8' }], - ] -}; - -/** The pure functions (`#[game_pure]`) of Cargo package `game_sim`: wrapper name → module export. */ -export const PURES = { - gridCell: { export: 'grid_cell', abi: 1 }, - gridSample: { export: 'grid_sample', abi: 1 }, -} as const; - -/** The compiled module (`distributed game-wasm`; shared with the predicted step); bundlers emit it as an asset. */ -export const PURES_WASM_URL: URL = new URL('./wasm/grid.wasm', import.meta.url); - -/** Typed calls of the compiled functions (synchronous; they throw until `ready` resolves). */ -export interface GridPureFns { - /** `grid_cell` (`dg_pure_grid_cell`). */ - gridCell(arg: GridContext): GridCellContent; - /** `grid_sample` (`dg_pure_grid_sample`). */ - gridSample(arg: GridPoint): GridSample; -} - -/** The `grid` pure functions plus `ready` / `loaded()` / `module()`. */ -export type GridPures = WasmPures; - -/** - * Load the compiled pure functions (default `PURES_WASM_URL`; a URL loaded by - * the predicted step too is instantiated once). - */ -export function createGridPures(source: WasmSource = PURES_WASM_URL): GridPures { - return createWasmPures(source, { - gridCell: { exportName: 'grid_cell', arg: GRID_CONTEXT_TYPE, output: GRID_CELL_CONTENT_TYPE }, - gridSample: { exportName: 'grid_sample', arg: GRID_POINT_TYPE, output: GRID_SAMPLE_TYPE }, - }); -} diff --git a/examples/game/client/src/generated/grid/schema.ts b/examples/game/client/src/generated/grid/schema.ts deleted file mode 100644 index 3adcaa89..00000000 --- a/examples/game/client/src/generated/grid/schema.ts +++ /dev/null @@ -1,129 +0,0 @@ -// @generated by `distributed game-client` from game surface `grid` (schema v3, protocol v1). -// Replicated kinds: ids, typed fields, wire types, correction defaults. -// Do not edit: change the Rust definitions and regenerate. -import { fieldNumber, fieldString, type CorrectionPolicies, type EntityFields, type WireType } from '@hops-ops/distributed/game'; - -/** Replicated kind ids by name. */ -export const KIND = { - walker: 1, - critter: 2, - marker: 3, -} as const; - -export type KindName = keyof typeof KIND; - -/** `walker` (kind 1, priority 2). */ -export interface WalkerFields { - /** `quant [-8, 40] / 18 bits` */ - readonly x: number; - /** `quant [-8, 40] / 18 bits` */ - readonly y: number; - /** `short_str`, priority 0.2 */ - readonly name: string; - /** `u8` */ - readonly hue: number; -} - -/** True for a `walker` entity. */ -export function isWalker(entity: { readonly kind: number }): boolean { - return entity.kind === 1; -} - -/** Typed fields of a `walker` entity (rendered or replica). */ -export function walkerFields(entity: { readonly fields: EntityFields }): WalkerFields { - const f = entity.fields; - return { - x: fieldNumber(f, 'x'), - y: fieldNumber(f, 'y'), - name: fieldString(f, 'name'), - hue: fieldNumber(f, 'hue'), - }; -} - -/** `critter` (kind 2, priority 1). */ -export interface CritterFields { - /** `quant [-8, 40] / 18 bits` */ - readonly x: number; - /** `quant [-8, 40] / 18 bits` */ - readonly y: number; - /** `angle / 8 bits` */ - readonly yaw: number; - /** `u8` */ - readonly hue: number; -} - -/** True for a `critter` entity. */ -export function isCritter(entity: { readonly kind: number }): boolean { - return entity.kind === 2; -} - -/** Typed fields of a `critter` entity (rendered or replica). */ -export function critterFields(entity: { readonly fields: EntityFields }): CritterFields { - const f = entity.fields; - return { - x: fieldNumber(f, 'x'), - y: fieldNumber(f, 'y'), - yaw: fieldNumber(f, 'yaw'), - hue: fieldNumber(f, 'hue'), - }; -} - -/** `marker` (kind 3, priority 1). */ -export interface MarkerFields { - /** `quant [-8, 40] / 18 bits` */ - readonly x: number; - /** `quant [-8, 40] / 18 bits` */ - readonly y: number; - /** `short_str` */ - readonly label: string; - /** `u8` */ - readonly hue: number; -} - -/** True for a `marker` entity. */ -export function isMarker(entity: { readonly kind: number }): boolean { - return entity.kind === 3; -} - -/** Typed fields of a `marker` entity (rendered or replica). */ -export function markerFields(entity: { readonly fields: EntityFields }): MarkerFields { - const f = entity.fields; - return { - x: fieldNumber(f, 'x'), - y: fieldNumber(f, 'y'), - label: fieldString(f, 'label'), - hue: fieldNumber(f, 'hue'), - }; -} - -/** Wire type of every replicated field, by kind and wire field name. */ -export const FIELD_TYPES = { - walker: { - x: { type: 'quant', min: -8, max: 40, bits: 18 }, - y: { type: 'quant', min: -8, max: 40, bits: 18 }, - name: { type: 'short_str' }, - hue: { type: 'u8' }, - }, - critter: { - x: { type: 'quant', min: -8, max: 40, bits: 18 }, - y: { type: 'quant', min: -8, max: 40, bits: 18 }, - yaw: { type: 'angle', bits: 8 }, - hue: { type: 'u8' }, - }, - marker: { - x: { type: 'quant', min: -8, max: 40, bits: 18 }, - y: { type: 'quant', min: -8, max: 40, bits: 18 }, - label: { type: 'short_str' }, - hue: { type: 'u8' }, - }, -} as const satisfies Record>; - -/** Client correction defaults (`#[correct(..)]`), by kind and wire field name. */ -export const CORRECTIONS: CorrectionPolicies = { - walker: { - x: { mode: 'ease', durationMs: 120 }, - y: { mode: 'ease', durationMs: 120 }, - name: { mode: 'replace' }, - hue: { mode: 'replace' }, - }, -}; diff --git a/examples/game/client/src/generated/grid/surface.json b/examples/game/client/src/generated/grid/surface.json deleted file mode 100644 index 154cd36a..00000000 --- a/examples/game/client/src/generated/grid/surface.json +++ /dev/null @@ -1,596 +0,0 @@ -{ - "constants": [ - { - "name": "grid_world", - "type": { - "name": "GridWorld", - "type": "record" - }, - "value": { - "biomes": [ - "highlands", - "steppe", - "forest", - "meadow" - ], - "cell": 32.0, - "content_version": 1, - "default_seed": 12648430, - "max_cell": 1000000, - "player_radius": 0.44999998807907104, - "prop_kinds": [ - "tree", - "rock", - "bush" - ], - "sample_spacing": 2.0, - "samples": 17 - } - } - ], - "events": [ - { - "id": 1, - "name": "hello", - "payload": { - "fields": [ - { - "name": "zone", - "type": { - "type": "short_str" - } - }, - { - "name": "cell", - "type": { - "item": { - "type": "i32" - }, - "type": "list" - } - }, - { - "name": "host", - "type": { - "type": "short_str" - } - }, - { - "name": "seed", - "type": { - "type": "u32" - } - }, - { - "name": "content_version", - "type": { - "type": "u32" - } - }, - { - "name": "cell_size", - "type": { - "type": "f32" - } - }, - { - "name": "entity", - "type": { - "item": { - "type": "u32" - }, - "type": "optional" - } - }, - { - "name": "restored", - "type": { - "type": "bool" - } - }, - { - "name": "restores", - "type": { - "type": "u32" - } - }, - { - "name": "hibernated_for_ms", - "type": { - "item": { - "type": "u64" - }, - "type": "optional" - } - }, - { - "name": "restored_critters", - "type": { - "type": "u32" - } - }, - { - "name": "restored_markers", - "type": { - "type": "u32" - } - }, - { - "name": "activated_at", - "type": { - "type": "u64" - } - } - ], - "kind": "fields" - } - } - ], - "input": { - "fields": [ - { - "name": "mx", - "range": [ - -127.0, - 127.0 - ], - "type": { - "type": "i8" - } - }, - { - "name": "my", - "range": [ - -127.0, - 127.0 - ], - "type": { - "type": "i8" - } - }, - { - "buttons": [ - "sprint", - "mark" - ], - "name": "buttons", - "type": { - "type": "u8" - } - } - ], - "name": "MoveInput" - }, - "kinds": [ - { - "fields": [ - { - "correction": { - "durationMs": 120, - "mode": "ease" - }, - "name": "x", - "priority": 1.0, - "type": { - "bits": 18, - "max": 40.0, - "min": -8.0, - "type": "quant" - } - }, - { - "correction": { - "durationMs": 120, - "mode": "ease" - }, - "name": "y", - "priority": 1.0, - "type": { - "bits": 18, - "max": 40.0, - "min": -8.0, - "type": "quant" - } - }, - { - "correction": { - "mode": "replace" - }, - "name": "name", - "priority": 0.2, - "type": { - "type": "short_str" - } - }, - { - "correction": { - "mode": "replace" - }, - "name": "hue", - "priority": 1.0, - "type": { - "type": "u8" - } - } - ], - "id": 1, - "name": "walker", - "priority": 2.0 - }, - { - "fields": [ - { - "correction": null, - "name": "x", - "priority": 1.0, - "type": { - "bits": 18, - "max": 40.0, - "min": -8.0, - "type": "quant" - } - }, - { - "correction": null, - "name": "y", - "priority": 1.0, - "type": { - "bits": 18, - "max": 40.0, - "min": -8.0, - "type": "quant" - } - }, - { - "correction": null, - "name": "yaw", - "priority": 1.0, - "type": { - "bits": 8, - "type": "angle" - } - }, - { - "correction": null, - "name": "hue", - "priority": 1.0, - "type": { - "type": "u8" - } - } - ], - "id": 2, - "name": "critter", - "priority": 1.0 - }, - { - "fields": [ - { - "correction": null, - "name": "x", - "priority": 1.0, - "type": { - "bits": 18, - "max": 40.0, - "min": -8.0, - "type": "quant" - } - }, - { - "correction": null, - "name": "y", - "priority": 1.0, - "type": { - "bits": 18, - "max": 40.0, - "min": -8.0, - "type": "quant" - } - }, - { - "correction": null, - "name": "label", - "priority": 1.0, - "type": { - "type": "short_str" - } - }, - { - "correction": null, - "name": "hue", - "priority": 1.0, - "type": { - "type": "u8" - } - } - ], - "id": 3, - "name": "marker", - "priority": 1.0 - } - ], - "name": "grid", - "predict": { - "abi": 1, - "context": "GridContext", - "export": "grid_step", - "kind": "walker", - "package": "game_sim", - "state": "WalkerState" - }, - "protocolVersion": 1, - "pures": [ - { - "abi": 1, - "arg": { - "name": "GridContext", - "type": "record" - }, - "export": "grid_cell", - "output": { - "name": "GridCellContent", - "type": "record" - }, - "package": "game_sim" - }, - { - "abi": 1, - "arg": { - "name": "GridPoint", - "type": "record" - }, - "export": "grid_sample", - "output": { - "name": "GridSample", - "type": "record" - }, - "package": "game_sim" - } - ], - "records": [ - { - "fields": [ - { - "name": "heights", - "type": { - "item": { - "type": "f32" - }, - "type": "list" - } - }, - { - "name": "moisture", - "type": { - "item": { - "type": "f32" - }, - "type": "list" - } - }, - { - "name": "biomes", - "type": { - "type": "bytes" - } - }, - { - "name": "props", - "type": { - "item": { - "name": "Prop", - "type": "record" - }, - "type": "list" - } - } - ], - "name": "GridCellContent" - }, - { - "fields": [ - { - "name": "seed", - "type": { - "type": "u32" - } - }, - { - "name": "cx", - "type": { - "type": "i32" - } - }, - { - "name": "cy", - "type": { - "type": "i32" - } - } - ], - "name": "GridContext" - }, - { - "fields": [ - { - "name": "seed", - "type": { - "type": "u32" - } - }, - { - "name": "cx", - "type": { - "type": "i32" - } - }, - { - "name": "cy", - "type": { - "type": "i32" - } - }, - { - "name": "x", - "type": { - "type": "f32" - } - }, - { - "name": "y", - "type": { - "type": "f32" - } - } - ], - "name": "GridPoint" - }, - { - "fields": [ - { - "name": "height", - "type": { - "type": "f32" - } - }, - { - "name": "moisture", - "type": { - "type": "f32" - } - }, - { - "name": "biome", - "type": { - "type": "u8" - } - } - ], - "name": "GridSample" - }, - { - "fields": [ - { - "name": "cell", - "type": { - "type": "f32" - } - }, - { - "name": "default_seed", - "type": { - "type": "u32" - } - }, - { - "name": "content_version", - "type": { - "type": "u32" - } - }, - { - "name": "samples", - "type": { - "type": "u8" - } - }, - { - "name": "sample_spacing", - "type": { - "type": "f32" - } - }, - { - "name": "player_radius", - "type": { - "type": "f32" - } - }, - { - "name": "max_cell", - "type": { - "type": "i32" - } - }, - { - "name": "biomes", - "type": { - "item": { - "type": "short_str" - }, - "type": "list" - } - }, - { - "name": "prop_kinds", - "type": { - "item": { - "type": "short_str" - }, - "type": "list" - } - } - ], - "name": "GridWorld" - }, - { - "fields": [ - { - "name": "kind", - "type": { - "type": "u8" - } - }, - { - "name": "x", - "type": { - "type": "f32" - } - }, - { - "name": "y", - "type": { - "type": "f32" - } - }, - { - "name": "r", - "type": { - "type": "f32" - } - }, - { - "name": "solid", - "type": { - "type": "bool" - } - }, - { - "name": "variant", - "type": { - "type": "u8" - } - } - ], - "name": "Prop" - }, - { - "fields": [ - { - "name": "x", - "type": { - "type": "f32" - } - }, - { - "name": "y", - "type": { - "type": "f32" - } - } - ], - "name": "WalkerState" - } - ], - "schemaVersion": 3 -} diff --git a/examples/game/client/src/grid/client.ts b/examples/game/client/src/grid/client.ts deleted file mode 100644 index 923799ad..00000000 --- a/examples/game/client/src/grid/client.ts +++ /dev/null @@ -1,242 +0,0 @@ -/** - * Endless Grid networking without rendering (Node-testable): - * - * - one `GameClient` whose authority is the cell the walker is in; it - * follows every Handoff (and the Redirect when the next cell lives on - * another host) — the SDK keeps rendering the old cell until the new one - * has content; - * - observer connections to the 8 neighbouring cells, re-synced whenever - * the authority changes, so what is across a border is visible before - * crossing; - * - prediction of the own walker by `grid_step`, the Rust step compiled to - * WebAssembly, which collides against the props of the cell named in its - * context (world seed + authoritative cell, set here); - * - world content for rendering — terrain lattice, biomes, props - * (`grid_cell`) and point samples (`grid_sample`) — from the Rust world - * generation compiled into the same module (`#[game_pure]`); nothing - * static comes over the wire and there is no TypeScript twin; - * - per-zone hello info (host, restored-from-hibernation …) and the - * `/dev/grid/map` world map. - */ -import { fieldNumber, type GameClient, type GameSocketFactory, type RenderedEntity } from '@hops-ops/distributed/game'; - -import * as grid from '../generated/grid/index.ts'; -import { zoneUrl, type DevApi } from '../shared/dev-api.ts'; - -export type MoveInput = grid.MoveInput; - -/** Static world facts (cell size, biome / prop names, lattice), generated from Rust. */ -export const WORLD = grid.GRID_WORLD; -export const CELL = WORLD.cell; -export const BUTTON_SPRINT = grid.BUTTON.sprint; -export const BUTTON_MARK = grid.BUTTON.mark; - -export type Cell = readonly [number, number]; -/** A cell's terrain lattice and props (`grid_cell`). */ -export type CellContent = grid.GridCellContent; -export type Prop = grid.Prop; -/** Terrain at one point (`grid_sample`). */ -export type Sample = grid.GridSample; - -export const DEFAULT_ZONE = 'grid.0.0'; - -/** `grid..` (the zone id naming scheme of `game_sim::grid::zone_id`). */ -export function zoneId(cell: Cell): string { - return `grid.${cell[0]}.${cell[1]}`; -} - -/** Inverse of `zoneId` (canonical integers within ±`WORLD.maxCell` only). */ -export function parseZone(zone: string): [number, number] | undefined { - const m = /^grid\.(-?\d+)\.(-?\d+)$/.exec(zone); - if (m === null) return undefined; - const x = Number(m[1]); - const y = Number(m[2]); - if (String(x) !== m[1] || String(y) !== m[2] || Math.abs(x) > WORLD.maxCell || Math.abs(y) > WORLD.maxCell) return undefined; - return [x, y]; -} - -/** A zone's hello (generated from the Rust `GridHello` event). */ -export type GridHello = grid.HelloEvent; - -export type MapCell = Readonly<{ - x: number; - y: number; - zone: string; - owner: string; - state: 'active' | 'hibernated' | 'cold' | 'unknown'; - tier?: string; - players?: number; - observers?: number; -}>; - -export type WorldMap = Readonly<{ - host: string; - hosts: readonly Readonly<{ id: string; wsUrl: string; reachable: boolean; activeTotal: number | null; active: number; hibernated: number }>[]; - cells: readonly MapCell[]; -}>; - -export type GridClientOptions = Readonly<{ - api: DevApi; - /** HTTP origin of the first host (Node); default: the page origin. */ - base?: string; - socket?: GameSocketFactory; - /** Observe the 8 neighbours (default true). */ - neighbours?: boolean; -}>; - -export type GridClient = Readonly<{ - client: GameClient; - /** The compiled `grid_step` (context: seed + authoritative cell). */ - prediction: grid.GridPrediction | undefined; - /** Cell of the authoritative zone. */ - cell(): Cell | undefined; - /** Latest hello per zone. */ - info(zone: string): GridHello | undefined; - /** Seed announced by the zones (falls back to the default). */ - seed(): number; - /** The compiled world generation (`grid_cell`, `grid_sample`). */ - pures: grid.GridPures; - /** Terrain lattice and props of a cell (memoized); undefined until the module loaded. */ - content(cell: Cell): CellContent | undefined; - /** Props of a cell; empty until the module loaded. */ - props(cell: Cell): readonly Prop[]; - /** Terrain at a local point (clamped into the cell); undefined until the module loaded. */ - sample(cell: Cell, x: number, y: number): Sample | undefined; - /** World position (m) of a rendered entity. */ - worldOf(entity: RenderedEntity): { x: number; y: number } | undefined; - /** Handoffs completed so far. */ - handoffs(): number; - /** Resolves on the first hello with an own entity, once the compiled module is loaded. */ - ready: Promise; - /** Called once per restored activation of any zone we see. */ - onRestored(listener: (hello: GridHello) => void): () => void; - /** Fetch the world map around a cell (`/dev/grid/map`). */ - map(center: Cell, radius: number): Promise; - close(): void; -}>; - -export function neighbours(cell: Cell): string[] { - const out: string[] = []; - for (let dy = -1; dy <= 1; dy += 1) { - for (let dx = -1; dx <= 1; dx += 1) { - if (dx !== 0 || dy !== 0) out.push(zoneId([cell[0] + dx, cell[1] + dy])); - } - } - return out; -} - -export function createGridClient(options: GridClientOptions): GridClient { - const infos = new Map(); - const contentCache = new Map(); - let seed = WORLD.defaultSeed; - let authorityCell: Cell | undefined; - let handoffs = 0; - const restoredSeen = new Set(); - const restoredListeners = new Set<(hello: GridHello) => void>(); - - // Same module URL as the predicted step: one fetch, one instance. - const pures = grid.createGridPures(); - const content = (cell: Cell): CellContent | undefined => { - if (!pures.loaded()) return undefined; - const key = `${seed}:${cell[0]}:${cell[1]}`; - let cached = contentCache.get(key); - if (cached === undefined) { - cached = pures.gridCell({ seed, cx: cell[0], cy: cell[1] }); - if (contentCache.size > 256) contentCache.clear(); - contentCache.set(key, cached); - } - return cached; - }; - const typed = grid.createGridClient({ - url: (zone) => zoneUrl(zone, options.base), - ticket: options.api.ticketSource(), - socket: options.socket, - pingIntervalMs: 1000, - inputDtMs: 1000 / 30 - // Prediction: the generated default, `grid_step` compiled to WASM; - // its context (seed + cell) follows the authority below. - }); - const { client } = typed; - const setContext = () => { - const cell = authorityCell ?? [0, 0]; - typed.prediction?.setContext({ seed, cx: cell[0], cy: cell[1] }); - }; - - let resolveReady: (h: GridHello) => void = () => undefined; - const ready = Promise.all([ - new Promise((resolve) => (resolveReady = resolve)), - typed.prediction?.ready, - pures.ready - ]).then(([h]) => h); - - const syncNeighbours = () => { - if (options.neighbours === false || authorityCell === undefined) return; - const authority = client.authority?.zoneId; - const wanted = new Set(neighbours(authorityCell)); - for (const zone of client.zones()) { - if (zone.role === 'observer' && !wanted.has(zone.zoneId)) client.unobserve(zone.zoneId); - } - for (const zone of wanted) { - if (zone === authority) continue; - const existing = client.zone(zone); - if (existing?.role === 'observer') continue; - client.observe(zone).catch(() => undefined); - } - }; - - client.on('authority', (zone) => { - authorityCell = parseZone(zone); - setContext(); - syncNeighbours(); - }); - client.on('handoffComplete', () => { - handoffs += 1; - }); - typed.on('hello', (hello, zone) => { - infos.set(zone, hello); - if (hello.seed !== seed) { - seed = hello.seed; - setContext(); - } - if (hello.entity !== undefined && zone === client.authority?.zoneId) { - client.setControlledEntity(hello.entity); - resolveReady(hello); - } - const key = `${zone}@${hello.activatedAt}`; - if (hello.restored && !restoredSeen.has(key)) { - restoredSeen.add(key); - for (const listener of restoredListeners) listener(hello); - } - }); - - return { - client, - prediction: typed.prediction, - cell: () => authorityCell, - info: (zone) => infos.get(zone), - seed: () => seed, - pures, - content, - props: (cell) => content(cell)?.props ?? [], - sample: (cell, x, y) => (pures.loaded() ? pures.gridSample({ seed, cx: cell[0], cy: cell[1], x, y }) : undefined), - worldOf(entity) { - const cell = parseZone(entity.zoneId); - if (cell === undefined) return undefined; - // Every grid kind has local x / y. - return { x: cell[0] * CELL + fieldNumber(entity.fields, 'x'), y: cell[1] * CELL + fieldNumber(entity.fields, 'y') }; - }, - handoffs: () => handoffs, - ready, - onRestored(listener) { - restoredListeners.add(listener); - return () => restoredListeners.delete(listener); - }, - async map(center, radius) { - const base = options.base ?? ''; - const response = await fetch(`${base}/dev/grid/map?x=${center[0]}&y=${center[1]}&r=${radius}`); - if (!response.ok) throw new Error(`/dev/grid/map: HTTP ${response.status}`); - return (await response.json()) as WorldMap; - }, - close: () => client.close() - }; -} diff --git a/examples/game/client/src/grid/grid.css b/examples/game/client/src/grid/grid.css deleted file mode 100644 index 0c190f7c..00000000 --- a/examples/game/client/src/grid/grid.css +++ /dev/null @@ -1,71 +0,0 @@ -.grid-toasts { - position: fixed; - top: 14px; - left: 50%; - transform: translateX(-50%); - display: flex; - flex-direction: column; - gap: 6px; - align-items: center; - pointer-events: none; - z-index: 5; - max-width: calc(100% - 32px); -} -.grid-toast { - padding: 7px 12px; - border-radius: 6px; - background: var(--panel); - border: 1px solid #2a3240; - transition: opacity 0.5s, transform 0.5s; -} -.grid-toast.restored { - border-color: #8fd3ff; - color: #cfeeff; -} -.grid-toast.host { - border-color: #ffb36b; -} -.grid-toast.error { - border-color: #ff6b6b; -} -.grid-toast.out { - opacity: 0; - transform: translateY(-6px); -} -.grid-map { - position: fixed; - right: 12px; - bottom: 12px; - padding: 8px; - border-radius: 8px; - background: var(--panel); -} -.grid-map canvas { - display: block; - width: 195px; - height: 195px; - image-rendering: pixelated; -} -.grid-legend { - margin-top: 6px; - font-size: 11px; - max-width: 195px; -} -.grid-legend .swatch { - display: inline-block; - width: 9px; - height: 9px; - margin-right: 5px; - border-radius: 2px; -} -.grid-legend small { - display: block; - margin-top: 3px; - color: var(--muted); -} -@media (max-width: 640px) { - .grid-map canvas { - width: 130px; - height: 130px; - } -} diff --git a/examples/game/client/src/grid/main.ts b/examples/game/client/src/grid/main.ts deleted file mode 100644 index 9ee01916..00000000 --- a/examples/game/client/src/grid/main.ts +++ /dev/null @@ -1,488 +0,0 @@ -/** - * Endless Grid page: Babylon scene over `createGridClient`. - * - * - terrain, biomes and props of the 5×5 cells around you are generated - * locally from the seed by the server's own Rust world generation, - * compiled to WebAssembly (`grid_cell` / `grid_sample` via the generated - * `createGridPures`) — nothing static comes over the wire; - * - cell borders are tinted by the host that runs the cell; the minimap - * (`/dev/grid/map`) shows active / hibernated / cold cells per host; - * - your walker is predicted; everything else (critters, other walkers, - * markers) is interpolated, from your cell and the 8 observed neighbours; - * - crossing a border hands you off (maybe to the other host); entering a - * cell that was hibernated shows a "restored from hibernation" toast. - * - * Rendering uses a floating origin at your current cell so coordinates stay - * small however far you walk. - */ -import { VertexBuffer } from '@babylonjs/core/Buffers/buffer'; -import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial'; -import { Color3, Color4 } from '@babylonjs/core/Maths/math.color'; -import { Matrix, Vector3 } from '@babylonjs/core/Maths/math.vector'; -import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh'; -import type { InstancedMesh } from '@babylonjs/core/Meshes/instancedMesh'; -import type { LinesMesh } from '@babylonjs/core/Meshes/linesMesh'; -import { Mesh } from '@babylonjs/core/Meshes/mesh'; -import { VertexData } from '@babylonjs/core/Meshes/mesh.vertexData'; -import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder'; -import { Scene } from '@babylonjs/core/scene'; -import { InputPacer, type RenderedEntity } from '@hops-ops/distributed/game'; - -import { DevApi } from '../shared/dev-api.ts'; -import { Hud } from '../shared/hud.ts'; -import { cameraRelative, InputSampler, toAxis } from '../shared/input.ts'; -import { createStage, material } from '../shared/scene.ts'; -import { critterFields, isCritter, isMarker, markerFields, walkerFields } from '../generated/grid/index.ts'; -import { - BUTTON_MARK, - BUTTON_SPRINT, - CELL, - createGridClient, - DEFAULT_ZONE, - parseZone, - WORLD, - zoneId, - type Cell, - type CellContent, - type GridHello, - type MapCell, - type MoveInput, - type WorldMap -} from './client.ts'; -import './grid.css'; - -const params = new URLSearchParams(location.search); -const startZone = params.get('zone') ?? DEFAULT_ZONE; -const name = params.get('name') ?? undefined; -const VIEW_CELLS = 2; -const RELIEF = 4; -const HOST_COLORS = ['#4ea1ff', '#ff9f43', '#b48cff', '#5fd39a']; -const PLAYER_RADIUS = WORLD.playerRadius; -/** Ground mesh: one vertex per terrain lattice point. */ -const SUBDIVISIONS = WORLD.samples - 1; -const PROP = Object.fromEntries(WORLD.propKinds.map((name, i) => [name, i])) as Record<'tree' | 'rock' | 'bush', number>; - -const api = new DevApi({ name: () => name }); -const grid = createGridClient({ api }); -const { client } = grid; - -const canvas = document.getElementById('stage') as HTMLCanvasElement; -const stage = createStage(canvas, { half: 1, background: '#a9c7e6' }); -const { scene, camera } = stage; -for (const mesh of [...scene.meshes]) if (mesh.name === 'floor' || mesh.name === 'wall') mesh.dispose(); -camera.radius = 26; -camera.upperRadiusLimit = 70; -scene.fogMode = Scene.FOGMODE_EXP2; -scene.fogDensity = 0.011; -scene.fogColor = Color3.FromHexString('#a9c7e6'); -scene.clearColor = Color4.FromHexString('#a9c7e6ff'); - -// ---- hosts ---- -const hostOrder: string[] = []; -function hostColor(host: string | undefined): string { - if (host === undefined) return '#8a8f99'; - if (!hostOrder.includes(host)) { - hostOrder.push(host); - hostOrder.sort(); - } - return HOST_COLORS[hostOrder.indexOf(host) % HOST_COLORS.length]!; -} -const owners = new Map(); - -// ---- floating origin ---- -let origin: Cell = parseZone(startZone) ?? [0, 0]; -function local(cell: Cell, x: number, y: number, up = 0): Vector3 { - return new Vector3((cell[0] - origin[0]) * CELL + x, terrain(cell, x, y) + up, (cell[1] - origin[1]) * CELL + y); -} -/** Ground height (m) at a local point (`grid_sample`, clamped into the cell). */ -function terrain(cell: Cell, x: number, y: number): number { - return (grid.sample(cell, x, y)?.height ?? 0) * RELIEF; -} - -/** Lattice index of a ground vertex (local coordinates on the lattice). */ -function lattice(lx: number, ly: number): number { - const step = WORLD.sampleSpacing; - return Math.round(ly / step) * WORLD.samples + Math.round(lx / step); -} - -// ---- terrain chunks ---- -const groundMat = new StandardMaterial('ground', scene); -groundMat.specularColor = Color3.Black(); -const BIOME_COLORS = [ - Color3.FromHexString('#8c8579'), - Color3.FromHexString('#c9b36b'), - Color3.FromHexString('#3d7638'), - Color3.FromHexString('#7cae4c') -]; - -/** Instancing templates; a tree is two instances (trunk + canopy). */ -const templates = (() => { - const trunk = MeshBuilder.CreateCylinder('trunk', { diameter: 0.5, height: 2.2, tessellation: 8 }, scene); - trunk.position.y = 1.1; - trunk.bakeCurrentTransformIntoVertices(); - trunk.material = material(scene, 'bark', '#6b4a2f'); - const canopy = MeshBuilder.CreateCylinder('canopy', { diameterTop: 0, diameterBottom: 3, height: 3.6, tessellation: 9 }, scene); - canopy.position.y = 3.6; - canopy.bakeCurrentTransformIntoVertices(); - canopy.material = material(scene, 'leaves', '#2f6b34'); - const rock = MeshBuilder.CreateIcoSphere('rock', { radius: 1, subdivisions: 1, flat: true }, scene); - rock.scaling.y = 0.6; - rock.bakeCurrentTransformIntoVertices(); - rock.material = material(scene, 'stone', '#6f737a'); - const bush = MeshBuilder.CreateSphere('bush', { diameter: 2, segments: 6 }, scene); - bush.scaling.y = 0.55; - bush.bakeCurrentTransformIntoVertices(); - bush.material = material(scene, 'shrub', '#4f8f3a'); - for (const m of [trunk, canopy, rock, bush]) m.setEnabled(false); - return { [PROP.tree]: [trunk, canopy], [PROP.rock]: [rock], [PROP.bush]: [bush] } as Record; -})(); - -// Props are not parented to the ground: instanced children of a moved -// parent rendered fully fogged (white) here, so they are placed directly. -type Chunk = { cell: Cell; content: CellContent; ground: Mesh; props: { mesh: InstancedMesh; at: Vector3 }[]; border: LinesMesh; owner?: string }; -const chunks = new Map(); - -function chunkColors(content: CellContent, ground: Mesh, owner: string | undefined): void { - const positions = ground.getVerticesData(VertexBuffer.PositionKind)!; - const colors = new Float32Array((positions.length / 3) * 4); - const tint = owner === undefined ? undefined : Color3.FromHexString(hostColor(owner)); - for (let i = 0; i < positions.length / 3; i += 1) { - const lx = positions[i * 3]! + CELL / 2; - const ly = positions[i * 3 + 2]! + CELL / 2; - const k = lattice(lx, ly); - const h = content.heights[k]!; - let c = BIOME_COLORS[content.biomes[k]!]!.scale(0.72 + h * 0.55); - if (lx % 8 === 0 || ly % 8 === 0) c = Color3.Lerp(c, Color3.FromHexString('#d9c9a3'), 0.28); // trails - if (tint !== undefined) c = Color3.Lerp(c, tint, 0.1); - colors.set([c.r, c.g, c.b, 1], i * 4); - } - ground.setVerticesData(VertexBuffer.ColorKind, colors); -} - -function createChunk(cell: Cell, content: CellContent): Chunk { - const key = zoneId(cell); - const ground = MeshBuilder.CreateGround(`ground-${key}`, { width: CELL, height: CELL, subdivisions: SUBDIVISIONS, updatable: true }, scene); - const positions = ground.getVerticesData(VertexBuffer.PositionKind)!; - for (let i = 0; i < positions.length / 3; i += 1) { - positions[i * 3 + 1] = content.heights[lattice(positions[i * 3]! + CELL / 2, positions[i * 3 + 2]! + CELL / 2)]! * RELIEF; - } - const normals: number[] = []; - VertexData.ComputeNormals(positions, ground.getIndices()!, normals); - ground.updateVerticesData(VertexBuffer.PositionKind, positions); - ground.updateVerticesData(VertexBuffer.NormalKind, normals); - ground.material = groundMat; - const owner = owners.get(key); - chunkColors(content, ground, owner); - - const props: Chunk['props'] = []; - for (const [i, p] of content.props.entries()) { - for (const [j, template] of templates[p.kind]!.entries()) { - const inst = template.createInstance(`prop-${key}-${i}-${j}`); - inst.scaling.setAll(p.kind === PROP.tree ? p.r / 0.55 : p.r); - inst.rotation.y = (p.variant / 255) * Math.PI * 2; - props.push({ mesh: inst, at: new Vector3(p.x - CELL / 2, terrain(cell, p.x, p.y) - 0.1, p.y - CELL / 2) }); - } - } - - const edge: Vector3[] = []; - const at = (x: number, y: number) => new Vector3(x - CELL / 2, terrain(cell, x, y) + 0.08, y - CELL / 2); - for (let k = 0; k <= 16; k += 1) edge.push(at(k * 2, 0)); - for (let k = 1; k <= 16; k += 1) edge.push(at(CELL, k * 2)); - for (let k = 15; k >= 0; k -= 1) edge.push(at(k * 2, CELL)); - for (let k = 15; k >= 0; k -= 1) edge.push(at(0, k * 2)); - const border = MeshBuilder.CreateLines(`border-${key}`, { points: edge }, scene); - border.parent = ground; - border.color = Color3.FromHexString(hostColor(owner)); - return { cell, content, ground, props, border, owner }; -} - -function syncChunks(): void { - const wanted = new Set(); - for (let dy = -VIEW_CELLS; dy <= VIEW_CELLS; dy += 1) { - for (let dx = -VIEW_CELLS; dx <= VIEW_CELLS; dx += 1) { - const cell: Cell = [origin[0] + dx, origin[1] + dy]; - const key = zoneId(cell); - wanted.add(key); - if (chunks.has(key)) continue; - // Content comes from the compiled module; until it loads there is nothing to draw. - const content = grid.content(cell); - if (content !== undefined) chunks.set(key, createChunk(cell, content)); - } - } - for (const [key, chunk] of chunks) { - if (!wanted.has(key)) { - for (const prop of chunk.props) prop.mesh.dispose(); - chunk.ground.dispose(false, true); - chunks.delete(key); - continue; - } - chunk.ground.position.set((chunk.cell[0] - origin[0]) * CELL + CELL / 2, 0, (chunk.cell[1] - origin[1]) * CELL + CELL / 2); - for (const prop of chunk.props) prop.mesh.position.copyFrom(chunk.ground.position.add(prop.at)); - const owner = owners.get(key); - if (owner !== chunk.owner) { - chunk.owner = owner; - chunk.border.color = Color3.FromHexString(hostColor(owner)); - chunkColors(chunk.content, chunk.ground, owner); - } - } -} - -function moveOrigin(to: Cell): void { - if (to[0] === origin[0] && to[1] === origin[1]) return; - const shift = new Vector3((to[0] - origin[0]) * CELL, 0, (to[1] - origin[1]) * CELL); - origin = to; - camera.setTarget(camera.target.subtract(shift), false, false, true); // keep the orbit angles - syncChunks(); -} - -// ---- entities ---- -function hueColor(hue: number, s = 0.65, v = 0.95): Color3 { - const c = new Color3(); - Color3.HSVtoRGBToRef((hue / 255) * 360, s, v, c); - return c; -} -const mats = new Map(); -function hueMat(hue: number, kind: string): StandardMaterial { - const key = `${kind}-${hue}`; - let m = mats.get(key); - if (m === undefined) { - m = new StandardMaterial(key, scene); - m.diffuseColor = hueColor(hue, kind === 'critter' ? 0.45 : 0.7); - m.specularColor = new Color3(0.1, 0.1, 0.1); - mats.set(key, m); - } - return m; -} -const meMat = material(scene, 'me', '#ffffff'); -meMat.emissiveColor = Color3.FromHexString('#2a4c66'); - -type Visual = { mesh: AbstractMesh; kind: 'walker' | 'critter' | 'marker' }; -const visuals = new Map(); -function visualFor(entity: RenderedEntity, mine: boolean): Visual { - let visual = visuals.get(entity.key); - if (visual !== undefined) return visual; - let mesh: AbstractMesh; - let kind: Visual['kind'] = 'walker'; - if (isCritter(entity)) { - const { hue } = critterFields(entity); - kind = 'critter'; - const body = MeshBuilder.CreateSphere(entity.key, { diameter: 0.7, segments: 8 }, scene); - body.scaling.set(1.2, 0.7, 0.8); - body.material = hueMat(hue, 'critter'); - mesh = body; - } else if (isMarker(entity)) { - const { hue } = markerFields(entity); - kind = 'marker'; - const pole = MeshBuilder.CreateCylinder(entity.key, { diameter: 0.08, height: 2.2 }, scene); - pole.bakeTransformIntoVertices(Matrix.Translation(0, 1.1, 0)); - const flag = MeshBuilder.CreateBox(`${entity.key}-flag`, { width: 0.8, height: 0.5, depth: 0.04 }, scene); - flag.parent = pole; - flag.position.set(0.42, 1.9, 0); - flag.material = hueMat(hue, 'marker'); - mesh = pole; - } else { - const { hue } = walkerFields(entity); - const capsule = MeshBuilder.CreateCapsule(entity.key, { radius: PLAYER_RADIUS, height: 1.8 }, scene); - capsule.material = mine ? meMat : hueMat(hue, 'walker'); - mesh = capsule; - } - visual = { mesh, kind }; - visuals.set(entity.key, visual); - return visual; -} - -// ---- HUD, toasts, minimap ---- -const hud = new Hud({ - title: 'Endless Grid', - hint: 'WASD / stick to walk, Space/Shift to sprint, E to drop a marker. Trails (light lines) are always free.', - client, - devApi: api -}); -const toasts = document.createElement('div'); -toasts.className = 'grid-toasts'; -document.body.append(toasts); -function toast(text: string, kind = ''): void { - const el = document.createElement('div'); - el.className = `grid-toast ${kind}`.trim(); - el.textContent = text; - toasts.append(el); - setTimeout(() => el.classList.add('out'), 4200); - setTimeout(() => el.remove(), 4800); -} -// Restores arrive in bursts (walking back wakes a cell and its neighbours): -// one toast per burst. -let restoredBurst: GridHello[] = []; -grid.onRestored((hello) => { - restoredBurst.push(hello); - if (restoredBurst.length > 1) return; - setTimeout(() => { - const burst = restoredBurst; - restoredBurst = []; - const here = burst.find((h) => h.zone === client.authority?.zoneId) ?? burst[0]!; - const slept = here.hibernatedForMs === undefined ? '' : ` after ${(here.hibernatedForMs / 1000).toFixed(1)} s`; - const others = burst.length > 1 ? ` (+${burst.length - 1} neighbour${burst.length > 2 ? 's' : ''})` : ''; - toast( - `${here.zone} restored from hibernation${slept} on host ${here.host}: ${here.restoredCritters} critters, ${here.restoredMarkers} markers${others}`, - 'restored' - ); - }, 400); -}); -let lastHost: string | undefined; -client.on('handoffComplete', ({ to }) => { - const host = grid.info(to)?.host; - if (host !== undefined && lastHost !== undefined && host !== lastHost) toast(`Crossed into ${to} — now on host ${host}`, 'host'); - lastHost = host ?? lastHost; -}); -client.on('handoffFailed', ({ to, reason }) => toast(`Handoff to ${to} failed: ${reason}`, 'error')); - -const mapBox = document.createElement('div'); -mapBox.className = 'grid-map'; -const mapCanvas = document.createElement('canvas'); -const MAP_R = 6; -const MAP_PX = 15; -mapCanvas.width = mapCanvas.height = (MAP_R * 2 + 1) * MAP_PX; -const legend = document.createElement('div'); -legend.className = 'grid-legend'; -mapBox.append(mapCanvas, legend); -document.body.append(mapBox); -let worldMap: WorldMap | undefined; - -function drawMap(): void { - const ctx = mapCanvas.getContext('2d'); - if (ctx === null || worldMap === undefined) return; - const center = grid.cell() ?? origin; - ctx.clearRect(0, 0, mapCanvas.width, mapCanvas.height); - const observed = new Set(client.zones().filter((z) => z.role === 'observer').map((z) => z.zoneId)); - for (const cell of worldMap.cells as MapCell[]) { - const px = (cell.x - center[0] + MAP_R) * MAP_PX; - const py = (MAP_R - (cell.y - center[1])) * MAP_PX; - if (px < 0 || py < 0 || px >= mapCanvas.width || py >= mapCanvas.height) continue; - ctx.globalAlpha = cell.state === 'active' ? 0.9 : cell.state === 'hibernated' ? 0.4 : 0.1; - ctx.fillStyle = hostColor(cell.owner); - ctx.fillRect(px + 1, py + 1, MAP_PX - 2, MAP_PX - 2); - ctx.globalAlpha = 1; - if (cell.state === 'hibernated') { - ctx.fillStyle = '#e8f1ff'; - ctx.font = '10px ui-monospace, monospace'; - ctx.fillText('z', px + 4, py + 11); - } - if (observed.has(cell.zone)) { - ctx.strokeStyle = 'rgba(255,255,255,0.45)'; - ctx.strokeRect(px + 1.5, py + 1.5, MAP_PX - 3, MAP_PX - 3); - } - if (cell.x === center[0] && cell.y === center[1]) { - ctx.strokeStyle = '#ffffff'; - ctx.lineWidth = 2; - ctx.strokeRect(px + 1, py + 1, MAP_PX - 2, MAP_PX - 2); - ctx.lineWidth = 1; - } - } - legend.replaceChildren( - ...worldMap.hosts.map((h) => { - const row = document.createElement('div'); - const swatch = document.createElement('span'); - swatch.className = 'swatch'; - swatch.style.background = hostColor(h.id); - row.append(swatch, `host ${h.id}: ${h.active} active, ${h.hibernated} asleep${h.reachable ? '' : ' (unreachable)'}`); - return row; - }), - Object.assign(document.createElement('small'), { textContent: 'bright = active · dim z = hibernated · faint = cold' }) - ); -} - -async function pollMap(): Promise { - try { - worldMap = await grid.map(grid.cell() ?? origin, MAP_R); - for (const cell of worldMap.cells) owners.set(cell.zone, cell.owner); - drawMap(); - } catch { - legend.textContent = 'world map unavailable (dev routes off?)'; - } -} -setInterval(() => void pollMap(), 1000); -void pollMap(); - -// ---- input ---- -const sampler = new InputSampler(); -const commandFrames = new InputPacer({ tickRate: () => client.authority?.connection.welcome?.tickRate }); -let lastInput: MoveInput = { mx: 0, my: 0, buttons: 0 }; -function sendCommandFrame(): void { - const s = sampler.sample(); - const move = cameraRelative(s, stage.cameraForward()); - lastInput = { - mx: toAxis(move.x), - my: toAxis(move.y), - buttons: (s.action ? BUTTON_SPRINT : 0) | (s.alt ? BUTTON_MARK : 0) - }; - client.sendInput(lastInput); -} - -// ---- frame ---- -syncChunks(); -// The world generation is compiled Rust: draw the chunks once it is loaded. -grid.pures.ready.then(syncChunks, (error: unknown) => hud.row('Status', `world module failed: ${String(error)}`)); -stage.onFrame((now, dtMs) => { - commandFrames.update(now, sendCommandFrame); - const cell = grid.cell(); - if (cell !== undefined) moveOrigin(cell); - const rendered = client.render(); - const seen = new Set(); - const hello = cell === undefined ? undefined : grid.info(zoneId(cell)); - let me: Vector3 | undefined; - let markers = 0; - for (const entity of rendered) { - const ecell = parseZone(entity.zoneId); - if (ecell === undefined) continue; - const mine = entity.predicted || (entity.authoritative && hello?.entity === entity.id); - const visual = visualFor(entity, mine); - seen.add(entity.key); - if (visual.kind === 'critter') { - const { x, y, yaw } = critterFields(entity); - visual.mesh.position.copyFrom(local(ecell, x, y, 0.3)); - visual.mesh.rotation.y = -yaw; - } else if (visual.kind === 'marker') { - const { x, y, label } = markerFields(entity); - visual.mesh.position.copyFrom(local(ecell, x, y)); - stage.labels.set(entity.key, visual.mesh, `⚑ ${label}`, 2.6); - if (entity.authoritative) markers += 1; - } else { - const { x, y, name: label } = walkerFields(entity); - visual.mesh.position.copyFrom(local(ecell, x, y, 0.9)); - stage.labels.set(entity.key, visual.mesh, mine ? `${label} (you)` : label, 1.4, mine ? 'me' : ''); - if (mine) me = visual.mesh.position; - } - visual.mesh.setEnabled(!entity.stale); - } - for (const [key, visual] of visuals) { - if (seen.has(key)) continue; - visual.mesh.dispose(false, true); - stage.labels.delete(key); - visuals.delete(key); - } - if (me !== undefined) stage.follow(me.x, me.z, dtMs); - - hud.frame({ entities: rendered.length }); - const observers = client.zones().filter((z) => z.role === 'observer'); - const predicted = client.predictor?.active === true ? client.predictor.predicted() : undefined; - const px = Number(predicted?.x ?? 16); - const py = Number(predicted?.y ?? 16); - hud.row('You', api.last === undefined ? '—' : `${api.last.name} · ${api.last.principal}`); - hud.row('Cell', cell === undefined ? '—' : `${cell[0]}, ${cell[1]} · local ${px.toFixed(1)}, ${py.toFixed(1)}`); - hud.row('Host', hello?.host ?? '—'); - const here = cell === undefined ? undefined : grid.sample(cell, px, py); - hud.row('Biome', here === undefined ? '—' : WORLD.biomes[here.biome]!); - hud.row('Neighbours', `${observers.filter((z) => z.connection.state === 'open').length}/8 observed`); - hud.row('Handoffs', String(grid.handoffs())); - hud.row('Markers here', String(markers)); - hud.row('Seed', `0x${grid.seed().toString(16)}`); -}); - -declare global { - interface Window { - /** Debug handle (console). */ - grid?: unknown; - } -} -window.grid = { grid, client, api, scene }; - -client.connect(startZone).catch((error: unknown) => hud.row('Status', `failed: ${String(error)}`)); diff --git a/examples/game/client/src/leaderboard/main.ts b/examples/game/client/src/leaderboard/main.ts index bd34cc5a..347824c1 100644 --- a/examples/game/client/src/leaderboard/main.ts +++ b/examples/game/client/src/leaderboard/main.ts @@ -162,4 +162,21 @@ declare global { } window.leaderboard = { client, api, pitApi, watcher }; -client.observe(zone).catch((error: unknown) => (status.textContent = `failed: ${String(error)}`)); +// A passive watcher: it never wakes the entrance; while it sleeps, retry. +const watchEntrance = () => { + client.unobserve(zone); + client.observe(zone).then( + () => { + if (status.textContent?.startsWith('the entrance')) status.textContent = ''; + }, + () => { + status.textContent = 'the entrance is asleep (nobody playing) — watching for it…'; + client.unobserve(zone); + setTimeout(watchEntrance, 3000); + } + ); +}; +client.on('zoneClosed', (closed) => { + if (closed === zone) setTimeout(watchEntrance, 3000); +}); +watchEntrance(); diff --git a/examples/game/client/src/mobpit/client.ts b/examples/game/client/src/mobpit/client.ts index 91155023..d641bb1e 100644 --- a/examples/game/client/src/mobpit/client.ts +++ b/examples/game/client/src/mobpit/client.ts @@ -16,11 +16,17 @@ * any room (the doorway arrival is the same world point, see * `game_sim::mobpit`). * + * Neighbours: the client also watches the 8 rooms around its own as an + * observer (`watchZones` over `neighbourRooms`), so their mobs are drawn where they are. On + * celld an observer is a passive watcher: it keeps no room awake and wakes + * none (a sleeping room answers `ASLEEP`; it is tried again every few + * seconds and shows nothing meanwhile). + * * Event flow of one kill: `kill` (broadcast, the tick the mob dies — the * fact is only *queued*) → `saved` (to the killer, when the durable cell * commit settled) → `profile`. */ -import type { EntityFields, GameSocketFactory, RenderedEntity } from '@hops-ops/distributed/game'; +import { watchZones, type EntityFields, type GameSocketFactory, type RenderedEntity, type WatchState } from '@hops-ops/distributed/game'; import * as pit from '../generated/mobpit/index.ts'; import type { DevApi } from '../shared/dev-api.ts'; @@ -67,6 +73,18 @@ export function roomZone(x: number, y: number): string { return `pit.${x}.${y}`; } +/** The 8 rooms around `zone`. */ +export function neighbourRooms(zone: string): string[] { + const at = parseRoom(zone); + if (at === undefined) return []; + const out: string[] = []; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) if (dx !== 0 || dy !== 0) out.push(roomZone(at[0] + dx, at[1] + dy)); + } + return out; +} + + /** * WebSocket URL of a room. `base` is the http(s) origin rooms are reached * at (celld, in Node tests); in the browser the page's own origin (Vite @@ -167,6 +185,10 @@ export type MobPitClient = pit.MobpitClient & pures: ReturnType; /** Resolves on the first hello, once the predicted step is loaded. */ ready: Promise; + /** The neighbour rooms watched (`watchZones` over `neighbourRooms`), when enabled. */ + neighbours(): ReadonlyMap; + /** Stop watching neighbours and close the client. */ + close(): void; }>; export type MobPitClientOptions = Readonly<{ @@ -174,6 +196,8 @@ export type MobPitClientOptions = Readonly<{ /** http(s) origin of the rooms (celld); default the page's origin. */ roomsBase?: string; socket?: GameSocketFactory; + /** Watch the 8 neighbour rooms (default true). */ + neighbours?: boolean; }>; export function createMobPitClient(options: MobPitClientOptions): MobPitClient { @@ -193,8 +217,9 @@ export function createMobPitClient(options: MobPitClientOptions): MobPitClient { ); const profile = latest((h) => typed.on('profile', h)); const unsaved = new Map(); - typed.on('kill', (kill) => { - if (kill.killer === hello.get()?.entity) unsaved.set(kill.key, kill); + typed.on('kill', (kill, zoneId) => { + // Entity ids are per room: only kills in your own room can be yours. + if (zoneId === typed.client.authority?.zoneId && kill.killer === hello.get()?.entity) unsaved.set(kill.key, kill); }); typed.on('saved', (saved) => unsaved.delete(saved.key)); const pures = pit.createMobpitPures(); @@ -202,9 +227,11 @@ export function createMobPitClient(options: MobPitClientOptions): MobPitClient { // bridge, the last predicted state in the room just left. const me = () => { const id = hello.get()?.entity; - const rendered = typed.client.render().filter((e) => pit.isPlayer(e)); + // Never a watched neighbour's player (entity ids are per room). + const rendered = typed.client.render().filter((e) => pit.isPlayer(e) && typed.client.zone(e.zoneId)?.role !== 'observer'); return rendered.find((e) => e.predicted) ?? rendered.find((e) => e.id === id && e.zoneId === typed.client.authority?.zoneId) ?? rendered.find((e) => e.id === id); }; + const watcher = options.neighbours === false ? undefined : watchZones(typed.client, neighbourRooms); return { ...typed, hello: hello.get, @@ -217,7 +244,12 @@ export function createMobPitClient(options: MobPitClientOptions): MobPitClient { }, unsaved, pures, - ready: Promise.all([hello.ready, typed.prediction?.ready, pures.ready]).then(([h]) => h) + ready: Promise.all([hello.ready, typed.prediction?.ready, pures.ready]).then(([h]) => h), + neighbours: () => watcher?.states() ?? new Map(), + close: () => { + watcher?.stop(); + typed.client.close(); + } }; } diff --git a/examples/game/client/src/mobpit/main.ts b/examples/game/client/src/mobpit/main.ts index dfe5517e..1319e4b4 100644 --- a/examples/game/client/src/mobpit/main.ts +++ b/examples/game/client/src/mobpit/main.ts @@ -143,7 +143,7 @@ function div(className: string, parent: HTMLElement): HTMLDivElement { const roomsPanel = div('pit-rooms', document.body); roomsPanel.innerHTML = `

Rooms

-
■ active ■ dormant ■ asleep ■ never ran
+
■ active ■ dormant ■ asleep ■ never ran □ watched
`; const roomsMap = roomsPanel.querySelector('canvas') as HTMLCanvasElement; const roomsField = (k: string) => roomsPanel.querySelector(`[data-k="${k}"]`) as HTMLElement; @@ -215,6 +215,11 @@ function drawRooms(): void { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.strokeRect(i * cell + 1, j * cell + 1, cell - 2, cell - 2); + } else if (pit.neighbours().get(zone) === 'watching') { + // Watched next door (passive: it never keeps the room awake). + ctx.strokeStyle = '#4dd0e1'; + ctx.lineWidth = 1; + ctx.strokeRect(i * cell + 2.5, j * cell + 2.5, cell - 5, cell - 5); } ctx.fillStyle = '#0e1116'; ctx.font = '10px monospace'; @@ -528,7 +533,8 @@ function popup(key: string, zoneId: string, x: number, y: number, text: string, } pit.on('kill', (kill, zoneId) => { - const mine = kill.killer === pit.hello()?.entity; + // Entity ids are per room: a kill next door is never yours. + const mine = zoneId === pit.room() && kill.killer === pit.hello()?.entity; const tier = TIERS[kill.tier]?.name ?? 'Mob'; if (mine) popup(kill.key, zoneId, kill.x, kill.y, `${tier} slain · saving…`, 'pending', 60_000); else popup(kill.key, zoneId, kill.x, kill.y, `${kill.killerName} slew a ${tier}`, 'other', 2000); @@ -741,6 +747,8 @@ stage.onFrame((now, dtMs) => { hud.row('Ack seq', String(predictor?.lastAck ?? 0)); hud.row('Corrections', `${corrections} (last ${lastCorrection.toFixed(3)} m)`); hud.row('Ghost gap', ghostGap === undefined ? '—' : `${ghostGap.toFixed(2)} m`); + const around = [...pit.neighbours().values()]; + hud.row('Next door', `${around.filter((s) => s === 'watching').length} rooms watched · ${around.filter((s) => s === 'asleep').length} asleep (not woken)`); hud.row('Interest', hello === undefined ? '—' : `${hello.viewRadius} m ring · ${rendered.filter((e) => isMob(e) && !e.stale).length} mobs replicated`); const profile = pit.profile(); if (profile !== undefined) { diff --git a/examples/game/client/src/mobpit/style.css b/examples/game/client/src/mobpit/style.css index bdec1b33..b831d6c7 100644 --- a/examples/game/client/src/mobpit/style.css +++ b/examples/game/client/src/mobpit/style.css @@ -149,6 +149,9 @@ .pit-rooms .c-cold { color: #556070; } +.pit-rooms .c-watched { + color: #4dd0e1; +} .pit-debug { margin-top: 6px; font: 11px/1.4 monospace; diff --git a/examples/game/client/tests/grid-headless.test.mjs b/examples/game/client/tests/grid-headless.test.mjs deleted file mode 100644 index d29b0f4a..00000000 --- a/examples/game/client/tests/grid-headless.test.mjs +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Headless Endless Grid client (Node, no browser) against two real - * `game-demos grid` processes (hosts A and B on a hash ring): - * - * - joins grid.0.0 and observes its 8 neighbours (critters across the - * border are rendered before crossing); - * - walks east along a trail over several borders, at least one of them - * between hosts, following every Handoff/Redirect with the SDK — the - * session survives and the walker ends in the expected cell; - * - the cell left behind hibernates (world map) and a new visitor gets - * the "restored from hibernation" hello; - * - the world content each crossed cell renders (terrain lattice, props) - * comes from the compiled Rust generation (`grid_cell`). - */ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { BUTTON_SPRINT, createGridClient, WORLD, zoneId } from '../src/grid/client.ts'; -import { DevApi } from '../src/shared/dev-api.ts'; -import { startCluster } from './helpers/cluster.mjs'; -import { memoryStorage, sleep, until } from './helpers/demo-server.mjs'; - -test('grid walker crosses cells and hosts; empty cells hibernate and restore', { timeout: 600_000 }, async () => { - const cluster = await startCluster('grid', { hibernateAfterMs: 800 }); - const clients = []; - try { - const base = cluster.httpUrl; - const api = new DevApi({ base, name: () => 'Walker', storage: memoryStorage() }); - const grid = createGridClient({ api, base }); - clients.push(grid); - const { client } = grid; - let failed = 0; - client.on('handoffFailed', () => (failed += 1)); - - await client.connect('grid.0.0'); - const hello = await grid.ready; - assert.deepEqual(hello.cell, [0, 0]); - // The module is loaded with the step: content is there for every cell. - const origin = grid.content([0, 0]); - assert.equal(origin.heights.length, WORLD.samples * WORLD.samples); - assert.ok(grid.props([0, 0]).length > 0, 'the origin cell has props'); - assert.equal(grid.sample([0, 0], 32, 32).height, grid.content([0, 0]).heights.at(-1)); - const principal = api.last.principal; - await until(() => client.predictor.active, 5_000, 'walker bound'); - await until( - () => client.zones().filter((z) => z.role === 'observer' && z.connection.welcome !== undefined).length === 8, - 10_000, - '8 neighbours observed' - ); - await until( - () => client.render().some((e) => e.kindName === 'critter' && e.zoneId === 'grid.1.0'), - 5_000, - 'critters across the east border' - ); - - // Pick a route east that crosses between hosts. - const map = await grid.map([0, 0], 8); - const owner = (x) => map.cells.find((c) => c.x === x && c.y === 0).owner; - assert.equal(owner(0), hello.host); - let last = 2; - while (![...Array(last).keys()].some((i) => owner(i) !== owner(i + 1))) last += 1; - assert.ok(last <= 8, 'a host border within 8 cells'); - const target = zoneId([last, 0]); - - const deadline = Date.now() + 60_000; - const hosts = new Set(); - while (!(client.authority?.zoneId === target && client.predictor.active && client.predictor.predicted().x > 4)) { - assert.ok(Date.now() < deadline, `walk timed out in ${client.authority?.zoneId}`); - client.sendInput({ mx: 127, my: 0, buttons: BUTTON_SPRINT }); - const zone = client.authority?.zoneId; - if (zone !== undefined && grid.info(zone) !== undefined) hosts.add(grid.info(zone).host); - await sleep(1000 / 30); - } - assert.equal(failed, 0, 'no failed handoff'); - assert.equal(grid.handoffs(), last, 'one handoff per border'); - assert.equal(grid.info(target).host, owner(last)); - assert.ok(hosts.size === 2, `visited both hosts: ${[...hosts]}`); - const ownerUrl = map.hosts.find((h) => h.id === owner(last)).wsUrl; - assert.ok(client.authority.connection.url.startsWith(ownerUrl), 'connected to the owning host'); - assert.equal(api.last.principal, principal, 'same guest throughout'); - await until(() => client.predictor.pending.length === 0, 5_000, 'acks'); - const me = client.authority.replica.get(grid.info(target).entity); - assert.equal(me.fields.name, 'Walker', 'name carried across hosts'); - assert.ok(Math.abs(me.fields.y - 16) < 0.01, 'stayed on the trail'); - assert.deepEqual(client.predictor.predicted().x, me.fields.x, 'prediction matches the server'); - // Neighbours follow the walker. - await until( - () => client.zones().filter((z) => z.role === 'observer').map((z) => z.zoneId).includes(zoneId([last + 1, 0])), - 5_000, - 'new east neighbour observed' - ); - - // grid.0.0 is no longer observed by anyone: Dormant, then hibernated. - let state = 'active'; - const hibernateDeadline = Date.now() + 15_000; - while (state !== 'hibernated') { - assert.ok(Date.now() < hibernateDeadline, `grid.0.0 stayed ${state}`); - await sleep(200); - state = (await grid.map([0, 0], 0)).cells[0].state; - } - - const visitor = createGridClient({ - api: new DevApi({ base, name: () => 'Visitor', storage: memoryStorage() }), - base, - neighbours: false - }); - clients.push(visitor); - const restored = new Promise((resolve) => visitor.onRestored(resolve)); - await visitor.client.connect('grid.0.0'); - const back = await restored; - assert.equal(back.zone, 'grid.0.0'); - assert.equal(back.restores, 1); - assert.ok(back.restoredCritters >= 2); - assert.ok(back.hibernatedForMs >= 0); - } finally { - for (const c of clients) c.close(); - await cluster.stop(); - } -}); diff --git a/examples/game/client/tests/mobpit-neighbours-headless.test.mjs b/examples/game/client/tests/mobpit-neighbours-headless.test.mjs new file mode 100644 index 00000000..283e8567 --- /dev/null +++ b/examples/game/client/tests/mobpit-neighbours-headless.test.mjs @@ -0,0 +1,70 @@ +/** + * Headless Mob Pit neighbours (Node): rooms on their own `celld dev`. + * + * A player watches the 8 rooms around its own as a passive observer: a + * sleeping room is not woken (it answers `ASLEEP`, nothing is drawn), an + * awake one streams its mobs, and the watcher does not keep it awake — when + * its last player leaves it goes dormant and then hibernates as if nobody + * looked, and the watcher shows nothing again. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { isMob } from '../src/generated/mobpit/index.ts'; +import { createMobPitClient, MobPitApi, neighbourRooms } from '../src/mobpit/client.ts'; +import { DevApi } from '../src/shared/dev-api.ts'; +import { eventually, memoryStorage, until } from './helpers/demo-server.mjs'; +import { startPit } from './helpers/mobpit.mjs'; + +test('neighbour rooms: passive watchers see mobs, never wake or keep a room awake', { timeout: 600_000 }, async () => { + const server = await startPit('mobpit'); + const clients = []; + try { + const pitApi = new MobPitApi(server.httpUrl, server.roomsUrl); + const player = (name, neighbours = true) => { + const pit = createMobPitClient({ + api: new DevApi({ base: server.httpUrl, name: () => name, storage: memoryStorage() }), + roomsBase: server.roomsUrl, + neighbours + }); + clients.push(pit); + return pit; + }; + const watcher = player('Watcher'); + await watcher.client.connect('pit.0.0'); + const hello = await watcher.ready; + assert.deepEqual(neighbourRooms('pit.0.0').length, 8); + const mobsIn = (zone) => watcher.client.render().filter((e) => isMob(e) && !e.stale && e.zoneId === zone).length; + + // Nobody ever played next door: every neighbour stays asleep (not woken). + await until(() => [...watcher.neighbours().values()].filter((s) => s === 'asleep').length === 8, 15_000, 'all 8 asleep'); + const east = await pitApi.roomInfo('pit.1.0'); + assert.equal(east.awake, false, 'watching did not wake the room'); + assert.equal(east.wakes, 0); + assert.equal(mobsIn('pit.1.0'), 0); + + // A player wakes pit.1.0: the watcher sees its mobs next door. + const other = player('Other', false); + await other.client.connect('pit.1.0'); + await other.ready; + const layout = watcher.pures.pitRoom({ seed: hello.seed, x: 1, y: 0 }); + await until(() => watcher.neighbours().get('pit.1.0') === 'watching', 15_000, 'watching pit.1.0'); + await until(() => mobsIn('pit.1.0') === layout.mobs.length, 10_000, 'every mob of pit.1.0'); + console.error(`watching pit.1.0: ${mobsIn('pit.1.0')} mobs`); + + // Its player leaves: the room sleeps although it is watched. + other.close(); + await eventually(async () => (await pitApi.rooms()).rooms.find((r) => r.zone === 'pit.1.0')?.status === 'dormant', 15_000, 'pit.1.0 dormant'); + await eventually(async () => (await pitApi.rooms()).rooms.find((r) => r.zone === 'pit.1.0')?.status === 'hibernated', 15_000, 'pit.1.0 hibernated while watched'); + assert.equal((await pitApi.roomInfo('pit.1.0')).awake, false); + await until(() => watcher.neighbours().get('pit.1.0') === 'asleep' && mobsIn('pit.1.0') === 0, 15_000, 'watcher lets go of the sleeping room'); + // And it stays asleep while the watcher keeps trying. + await new Promise((r) => setTimeout(r, 4000)); + const after = await pitApi.roomInfo('pit.1.0'); + assert.equal(after.awake, false, 'still asleep'); + assert.ok(after.wakes <= 1, 'woken only by the player'); + } finally { + for (const c of clients) c.close(); + await server.stop(); + } +}); diff --git a/examples/game/client/tests/prediction-wasm.test.mjs b/examples/game/client/tests/prediction-wasm.test.mjs index ca1afe21..29f212c7 100644 --- a/examples/game/client/tests/prediction-wasm.test.mjs +++ b/examples/game/client/tests/prediction-wasm.test.mjs @@ -10,7 +10,7 @@ * produce the same bytes — every float bit included. The generated * `predict` (state ↔ entity fields, input encoder) and pure wrappers * (`createGridPures`) are checked over the same data; a `Predictor` - * reconciling with 10 pending inputs and generating a grid cell are timed. + * reconciling with 10 pending inputs and generating a room are timed. * * The code only uses operations that are correctly rounded on every target * (`+ - * /`, `sqrt`, `floor`, comparisons); see @@ -59,7 +59,7 @@ const surfaces = readdirSync(GENERATED) }); test('every predicting demo has a compiled step and a native trace', () => { - assert.deepEqual(surfaces.map((s) => s.name).sort(), ['freeze', 'grid', 'mobpit', 'ripple']); + assert.deepEqual(surfaces.map((s) => s.name).sort(), ['freeze', 'mobpit', 'ripple']); assert.equal(traces.abi, 1); for (const { name, predict } of surfaces) { assert.ok(existsSync(new URL(`${name}/wasm/${name}.wasm`, GENERATED)), `${name}: run \`run.sh gen\``); @@ -117,7 +117,7 @@ const pureSurfaces = readdirSync(GENERATED) .map((name) => ({ name, pures: JSON.parse(readFileSync(new URL(`${name}/surface.json`, GENERATED), 'utf8')).pures })); test('every pure function has a native trace and every trace a surface', () => { - assert.deepEqual(pureSurfaces.map((s) => s.name), ['grid', 'mobpit']); + assert.deepEqual(pureSurfaces.map((s) => s.name), ['mobpit']); const declared = pureSurfaces.flatMap((s) => s.pures.map((p) => p.export)).sort(); assert.deepEqual(traces.pures.map((p) => p.export).sort(), declared); }); @@ -136,32 +136,24 @@ for (const { name, pures } of pureSurfaces) { } } -test('grid: the generated wrappers decode what the native generation encodes', async () => { - const generated = await import('../src/generated/grid/index.ts'); - const pures = generated.createGridPures(); +test('mobpit: the generated pitRoom wrapper decodes what the native generation encodes', async () => { + const generated = await import('../src/generated/mobpit/index.ts'); + const pures = generated.createMobpitPures(); await pures.ready; - const types = { - grid_cell: pureType(generated, 'grid_cell'), - grid_sample: pureType(generated, 'grid_sample') - }; - for (const pure of traces.pures.filter((p) => p.export in types)) { - const { arg, output } = types[pure.export]; - const wrapper = pure.export === 'grid_cell' ? pures.gridCell : pures.gridSample; - for (const call of pure.calls.slice(0, 12)) { - const value = wrapper(decodeValue(arg, fromHex(call.arg))); - assert.equal(toHex(encodeValue(output, value)), call.output); - } + const { arg, output } = pureType(generated, 'mobpit', 'pit_room'); + const trace = traces.pures.find((p) => p.export === 'pit_room'); + for (const call of trace.calls.slice(0, 12)) { + const value = pures.pitRoom(decodeValue(arg, fromHex(call.arg))); + assert.equal(toHex(encodeValue(output, value)), call.output); } - // Lattice and props as the page draws them. - const cell = pures.gridCell({ seed: generated.GRID_WORLD.defaultSeed, cx: 0, cy: 0 }); - assert.equal(cell.heights.length, generated.GRID_WORLD.samples ** 2); - assert.ok(cell.props.every((p) => p.kind < generated.GRID_WORLD.propKinds.length)); - assert.ok([...cell.biomes].every((b) => b < generated.GRID_WORLD.biomes.length)); + const room = pures.pitRoom({ seed: generated.PIT_WORLD.defaultSeed, x: 0, y: 0 }); + assert.ok(room.biome < generated.PIT_WORLD.biomes.length); + assert.ok(room.mobs.length >= 6); }); /** Argument / output wire types of a pure function, from its surface (records resolved, camelCase fields). */ -function pureType(generated, exportName) { - const surface = JSON.parse(readFileSync(new URL('grid/surface.json', GENERATED), 'utf8')); +function pureType(generated, surfaceName, exportName) { + const surface = JSON.parse(readFileSync(new URL(`${surfaceName}/surface.json`, GENERATED), 'utf8')); const camel = (n) => n.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase()); const resolve = (type) => { if (type.type === 'record') { @@ -187,43 +179,33 @@ function measure(fn, iterations, runs = 7) { return samples[Math.floor(runs / 2)]; } -test('cost: generating a grid cell and sampling a point in WASM', async () => { - const generated = await import('../src/generated/grid/index.ts'); - const pures = generated.createGridPures(); +test('cost: generating a room in WASM', async () => { + const generated = await import('../src/generated/mobpit/index.ts'); + const pures = generated.createMobpitPures(); await pures.ready; - const seed = generated.GRID_WORLD.defaultSeed; - // A different cell every call (no cache inside or outside the module). - const cellUs = measure((i) => pures.gridCell({ seed, cx: (i % 97) - 48, cy: Math.floor(i / 97) - 20 }), 400); - // Without decoding the result into JS objects (generation + ABI copy). - const argType = pureType(generated, 'grid_cell').arg; - const abiUs = measure((i) => pures.module().call('grid_cell', encodeValue(argType, { seed, cx: (i % 89) - 44, cy: -7 })), 400); - const sampleUs = measure((i) => pures.gridSample({ seed, cx: 3, cy: -2, x: (i % 320) / 10, y: 7.25 }), 20_000); - // The page builds a ring of up to 5 new cells per border crossing; it must - // fit in a frame with room to spare. - console.log( - `grid generation cost (Node ${process.version}, µs, median): ${JSON.stringify({ cellUs, cellAbiOnlyUs: abiUs, sampleUs })}` - ); - assert.ok(cellUs < 2000, `grid_cell took ${cellUs} µs`); - assert.ok(sampleUs < 100, `grid_sample took ${sampleUs} µs`); + const seed = generated.PIT_WORLD.defaultSeed; + // A different room every call (no cache inside or outside the module). + const roomUs = measure((i) => pures.pitRoom({ seed, x: (i % 97) - 48, y: Math.floor(i / 97) - 20 }), 2000); + console.log(`room generation cost (Node ${process.version}, µs, median): ${JSON.stringify({ roomUs })}`); + // The page builds up to 5 rooms per doorway; it must fit in a frame easily. + assert.ok(roomUs < 500, `pit_room took ${roomUs} µs`); }); test('cost: one predicted step and a reconciliation replaying 10 inputs at 30 Hz', async () => { const report = {}; - for (const name of ['mobpit', 'grid']) { + for (const name of ['mobpit', 'ripple']) { const generated = await import(`../src/generated/${name}/index.ts`); const prediction = generated[`create${name[0].toUpperCase()}${name.slice(1)}Prediction`](); await prediction.ready; - if (name === 'grid') prediction.setContext({ seed: 0x00c0ffee, cx: 3, cy: 2 }); const kind = { id: 1, name: generated.PREDICT.kind, priority: 1, fields: generated.PREDICT_STATE.map(([field]) => ({ name: field, type: 'f32', priority: 1 })) }; - const start = name === 'grid' ? { x: 16, y: 16 } : { x: -5, y: -5, vx: 0, vy: 0, dash: 0, cd: 0 }; + const start = { x: -5, y: -5, vx: 0, vy: 0, dash: 0, cd: 0 }; const input = { mx: 90, my: -40, buttons: 0 }; // Raw ABI call (encoded bytes in, bytes out). const module = prediction.module(); const stateBytes = encodePredictState(generated.PREDICT_STATE, start); const inputBytes = generated.encodeInput(input); - const contextBytes = - name === 'grid' ? encodePayload(generated.PREDICT_CONTEXT, prediction.context()) : new Uint8Array(0); + const contextBytes = new Uint8Array(0); const abiUs = measure(() => module.step(generated.PREDICT.export, stateBytes, inputBytes, contextBytes, DT), 20_000); // `predict` as GameClient calls it (fields → bytes → step → fields). const predictUs = measure(() => prediction.predict(start, input, 1000 / 30), 20_000); diff --git a/examples/game/client/vite.config.ts b/examples/game/client/vite.config.ts index 43ae0925..58cea9da 100644 --- a/examples/game/client/vite.config.ts +++ b/examples/game/client/vite.config.ts @@ -7,7 +7,7 @@ import { resolve } from 'node:path'; import { defineConfig } from 'vite'; -const DEMOS = ['mobpit', 'leaderboard', 'gallery', 'freeze', 'grid', 'ripple']; +const DEMOS = ['mobpit', 'leaderboard', 'gallery', 'freeze', 'ripple']; const server = process.env.GAME_DEMOS_SERVER ?? 'http://127.0.0.1:2567'; const rooms = process.env.GAME_DEMOS_ROOMS ?? 'http://127.0.0.1:2570'; diff --git a/examples/game/run.sh b/examples/game/run.sh index 77cd6029..cea639d6 100755 --- a/examples/game/run.sh +++ b/examples/game/run.sh @@ -10,7 +10,7 @@ # 3) in production mode on one RustFS bucket # (docker compose), entered round-robin, so rooms # land on different nodes (debug overlay shows which) -# ./run.sh grid|ripple same with TWO hosts (A on GAME_DEMOS_PORT, B on +# ./run.sh ripple Boss Ripple with TWO hosts (A on GAME_DEMOS_PORT, B on # the next port), one process each # ./run.sh gen regenerate client/src/generated// from the # Rust surfaces (`distributed game-manifest` + @@ -63,16 +63,16 @@ SVELTEKIT="$HERE/sveltekit" BIN="$SERVER/target/debug/game-demos" SERVICE_BIN="$SERVER/target/debug/mobpit-service" CELLS="$HERE/cells" -DEMOS=(mobpit leaderboard gallery freeze grid ripple) +DEMOS=(mobpit leaderboard gallery freeze ripple) # Single-process demos whose durable state lives in celld cells (the # multi-host ones always run on celld too: zone leases and hibernation). CELLD_DEMOS=(mobpit leaderboard freeze) export MOBPIT_DATA_DIR="${MOBPIT_DATA_DIR:-$HERE/.data/mobpit}" # Multi-host demos: `game-demos --host-id A|B --peer …`. -CLUSTER_DEMOS=(grid ripple) +CLUSTER_DEMOS=(ripple) # Game surfaces with a generated client (`game_demos::::game_surface`); # the leaderboard page uses Mob Pit's. -SURFACES=(mobpit gallery freeze grid ripple) +SURFACES=(mobpit gallery freeze ripple) CLI="$REPO/target/debug/distributed" log() { printf '\033[1;36m[demos]\033[0m %s\n' "$*"; } @@ -579,7 +579,7 @@ run_sveltekit() { smoke() { ensure_sdk ensure_client - # The celld-backed tests (Mob Pit, The Freeze, grid, ripple) start their + # The celld-backed tests (Mob Pit, The Freeze, ripple) start their # own `celld dev` (CELLD_BIN) on the cells worker. ensure_celld ensure_cells diff --git a/examples/game/server/rooms/src/zone.rs b/examples/game/server/rooms/src/zone.rs index de18f9e7..5a7563be 100644 --- a/examples/game/server/rooms/src/zone.rs +++ b/examples/game/server/rooms/src/zone.rs @@ -368,6 +368,9 @@ const MAX_CREDITS: u8 = 4; /// (the page draws it as the interest ring). From any spawn point every mob /// home is inside it; walk to a corner and the far side drops out. pub const VIEW_RADIUS: f32 = 24.0; +/// What a watcher sees (from the room's centre: the whole room). On celld a +/// watcher is passive: it never keeps the room awake. +pub const WATCH_RADIUS: f32 = 30.0; const AGGRO: f32 = 7.0; const LEASH: f32 = 15.0; const WINDUP_TICKS: u16 = 24; @@ -1246,8 +1249,9 @@ impl Zone for MobPit { } } + /// A watcher (a player next door, a board page): the whole room. fn on_observe(&mut self, ctx: &mut Ctx<'_, Self>, observer: ObserverId) { - ctx.set_view(observer, View::new(0.0, 0.0, VIEW_RADIUS)); + ctx.set_view(observer, View::new(0.0, 0.0, WATCH_RADIUS)); } fn decode_input(&self, _player: PlayerId, payload: &[u8]) -> Option { diff --git a/examples/game/server/sim/src/grid.rs b/examples/game/server/sim/src/grid.rs index eeea74a7..ad814c30 100644 --- a/examples/game/server/sim/src/grid.rs +++ b/examples/game/server/sim/src/grid.rs @@ -1,23 +1,10 @@ -//! Procedural world and movement of the Endless Grid. +//! The seeded world generation (from the Endless Grid demo): hashing, +//! value noise, biomes and props of 32 m cells. Mob Pit's rooms build on +//! it ([`crate::mobpit::pit_room`]: a room's biome); it is a pure function +//! of `(world seed, cell x, cell y, CONTENT_VERSION)`, compiled natively +//! and to WebAssembly. //! -//! The static content of a cell (terrain height, biome, props) is a pure -//! function of `(world seed, cell x, cell y, CONTENT_VERSION)`; the server -//! uses it for collision and the client for rendering, and **nothing -//! static is replicated**. The browser runs this very code compiled to -//! WebAssembly (`distributed game-wasm`), so there is no client twin: -//! -//! - [`grid_step`] (`#[predict]`) predicts the own walker; its context -//! names the seed and cell, so the props are generated inside the module; -//! - [`grid_cell`] (`#[game_pure]`) returns what the page draws for a cell: -//! the terrain lattice ([`SAMPLES`]² heights, moisture, biomes) and the -//! props; -//! - [`grid_sample`] (`#[game_pure]`) samples one point (entity heights, -//! the HUD's biome); -//! - [`grid_world`] is the generated `GRID_WORLD` constant (cell size, -//! biome and prop names, lattice spacing, default seed). -//! -//! `client/tests/prediction-wasm.test.mjs` requires the WASM results to -//! equal the native ones byte for byte. Rules that keep them identical: +//! Rules that keep them identical: //! integer hashing on `u32` only; floats are binary32 using only //! `+ - * /`, `sqrt` and `floor` (see `distributed_game::prediction`). //! @@ -28,11 +15,9 @@ //! is a multiple of 8 m is a free path ("trail"), and props never reach //! across a cell border. -use std::cell::RefCell; - use distributed_game::codec::{Wire, WireType}; use distributed_game::protocol::{DecodeError, Reader, Writer}; -use distributed_game::{game_pure, predict, FieldType, FieldValue, GameInput, GameRecord}; +use distributed_game::GameRecord; /// Cell edge length (m). pub const CELL: f32 = 32.0; @@ -48,29 +33,8 @@ pub const SUB_SIZE: f32 = 8.0; pub const PROP_MARGIN: f32 = 2.0; /// Prop placement span inside a sub-cell: `SUB_SIZE - 2 * PROP_MARGIN`. pub const PROP_SPAN: f32 = 4.0; -/// Handoff hysteresis (m): a walker belongs to the neighbour once it is -/// this far past the border, and arrives this far inside it. -pub const HYSTERESIS: f32 = 0.5; /// Walker radius (m). pub const PLAYER_RADIUS: f32 = 0.45; -/// Walking speed (m/s). -pub const WALK_SPEED: f32 = 6.0; -/// Sprint speed (m/s). -pub const SPRINT_SPEED: f32 = 10.0; -/// Command frame length (s): the 30 Hz tick. -pub const DT: f32 = 1.0 / 30.0; -/// How far outside its cell a walker may be before it is clamped (m). -pub const OUTSIDE_LIMIT: f32 = 2.0; -/// Input bit: sprint. -pub const BUTTON_SPRINT: u8 = MoveInput::SPRINT; -/// Input bit: drop a marker. -pub const BUTTON_MARK: u8 = MoveInput::MARK; -/// Wire type of local positions (covers the walker's slack outside the cell). -pub const POS: FieldType = FieldType::QuantF32 { - min: -8.0, - max: 40.0, - bits: 18, -}; /// Biomes, by index. pub const BIOMES: [&str; 4] = ["highlands", "steppe", "forest", "meadow"]; @@ -275,30 +239,6 @@ pub fn cell_props(seed: u32, cell: (i32, i32)) -> Vec { props } -/// One command frame: stick per axis in `[-127, 127]` and button bits. -/// Wire payload `[mx, my, buttons]`; `GameInput::from_payload` clamps `-128` -/// to `-127` and clears unknown buttons (the client's encoder is generated). -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, GameInput)] -pub struct MoveInput { - /// Stick x (+east). - #[range(min = -127, max = 127)] - pub mx: i8, - /// Stick y (+north). - #[range(min = -127, max = 127)] - pub my: i8, - /// [`MoveInput::SPRINT`] | [`MoveInput::MARK`]. - #[buttons(sprint, mark)] - pub buttons: u8, -} - -/// Snap to the wire quantization so the server holds exactly what clients decode. -pub fn snap(v: f32) -> f32 { - match POS.canonicalize(&FieldValue::F(v)) { - FieldValue::F(v) => v, - _ => v, - } -} - /// Push a circle of radius `radius` at `(x, y)` out of every solid prop. pub fn collide(mut x: f32, mut y: f32, radius: f32, props: &[Prop]) -> (f32, f32) { for p in props.iter().filter(|p| p.solid) { @@ -320,262 +260,10 @@ pub fn collide(mut x: f32, mut y: f32, radius: f32, props: &[Prop]) -> (f32, f32 (x, y) } -/// Advance a walker one command frame inside its cell (`props` = the -/// cell's props). Positions may leave `0..CELL` by up to -/// [`OUTSIDE_LIMIT`]; the zone hands the walker off past [`HYSTERESIS`]. -pub fn step(x: f32, y: f32, input: MoveInput, props: &[Prop]) -> (f32, f32) { - let mut dx = f32::from(input.mx) / 127.0; - let mut dy = f32::from(input.my) / 127.0; - let l2 = dx * dx + dy * dy; - if l2 > 1.0 { - let inv = 1.0 / l2.sqrt(); - dx *= inv; - dy *= inv; - } - let speed = if input.buttons & BUTTON_SPRINT != 0 { - SPRINT_SPEED - } else { - WALK_SPEED - }; - let x = x + dx * speed * DT; - let y = y + dy * speed * DT; - let (x, y) = collide(x, y, PLAYER_RADIUS, props); - let lo = -OUTSIDE_LIMIT; - let hi = CELL + OUTSIDE_LIMIT; - (snap(x.clamp(lo, hi)), snap(y.clamp(lo, hi))) -} - -/// A walker's predicted state: its cell-local position (the `walker` kind's -/// `x` / `y`). -#[derive(Debug, Clone, Copy, Default, PartialEq, GameRecord)] -pub struct WalkerState { - /// Local x. - pub x: f32, - /// Local y. - pub y: f32, -} - -/// What a client tells the predicted step: the world seed (from the -/// zone's hello) and the cell it walks in (its authoritative zone). -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, GameRecord)] -pub struct GridContext { - /// World seed. - pub seed: u32, - /// Cell x. - pub cx: i32, - /// Cell y. - pub cy: i32, -} - -/// `(seed, cell x, cell y)` → that cell's props. -type PropsCache = Option<((u32, i32, i32), Vec)>; - -thread_local! { - /// Props of the last cell stepped in (a walker stays in one cell for - /// many frames). - static PROPS: RefCell = const { RefCell::new(None) }; -} - -/// The client-predicted step of the own walker: [`step`] against the -/// props of the context's cell (generated here, so the browser needs no -/// copy of the world to predict). -#[predict(GridStep, kind = "walker")] -pub fn grid_step( - state: &WalkerState, - input: &MoveInput, - context: &GridContext, - _dt: f32, -) -> WalkerState { - let key = (context.seed, context.cx, context.cy); - PROPS.with(|cache| { - let mut cache = cache.borrow_mut(); - if cache.as_ref().map(|(k, _)| *k) != Some(key) { - *cache = Some((key, cell_props(context.seed, (context.cx, context.cy)))); - } - let props = &cache.as_ref().expect("filled above").1; - let (x, y) = step(state.x, state.y, *input, props); - WalkerState { x, y } - }) -} - -/// Terrain lattice points per cell axis (every [`SAMPLE_SPACING`] m, -/// borders included). -pub const SAMPLES: usize = 17; -/// Distance between lattice points (m). -pub const SAMPLE_SPACING: f32 = 2.0; - -/// What the client draws for one cell ([`grid_cell`]). -#[derive(Debug, Clone, PartialEq, GameRecord)] -pub struct GridCellContent { - /// Terrain height in `[0, 1)` at lattice point `(i, j)` (local - /// `(i, j) * SAMPLE_SPACING`), row-major by `j` (index `j * SAMPLES + i`). - pub heights: Vec, - /// Moisture in `[0, 1)`, same lattice. - pub moisture: Vec, - /// Biome index into [`BIOMES`], same lattice. - pub biomes: Vec, - /// The cell's props ([`cell_props`]). - pub props: Vec, -} - -/// A point of a cell ([`grid_sample`]). -#[derive(Debug, Clone, Copy, Default, PartialEq, GameRecord)] -pub struct GridPoint { - /// World seed. - pub seed: u32, - /// Cell x. - pub cx: i32, - /// Cell y. - pub cy: i32, - /// Local x (clamped to `0..=CELL`). - pub x: f32, - /// Local y (clamped to `0..=CELL`). - pub y: f32, -} - -/// Terrain at one point. -#[derive(Debug, Clone, Copy, PartialEq, GameRecord)] -pub struct GridSample { - /// Height in `[0, 1)`. - pub height: f32, - /// Moisture in `[0, 1)`. - pub moisture: f32, - /// Biome index into [`BIOMES`]. - pub biome: u8, -} - -/// Static facts of the world the client needs (generated `GRID_WORLD`). -#[derive(Debug, Clone, PartialEq, GameRecord)] -pub struct GridWorld { - /// Cell edge length (m). - pub cell: f32, - /// Default world seed (before a zone's hello names it). - pub default_seed: u32, - /// Generation version. - pub content_version: u32, - /// Terrain lattice points per cell axis ([`SAMPLES`]). - pub samples: u8, - /// Lattice spacing (m). - pub sample_spacing: f32, - /// Walker radius (m). - pub player_radius: f32, - /// Largest |cell coordinate|. - pub max_cell: i32, - /// Biome names by index. - pub biomes: Vec, - /// Prop kind names by index. - pub prop_kinds: Vec, -} - -/// The `GRID_WORLD` constant. -pub fn grid_world() -> GridWorld { - GridWorld { - cell: CELL, - default_seed: DEFAULT_SEED, - content_version: CONTENT_VERSION, - samples: SAMPLES as u8, - sample_spacing: SAMPLE_SPACING, - player_radius: PLAYER_RADIUS, - max_cell: MAX_CELL, - biomes: BIOMES.map(String::from).to_vec(), - prop_kinds: PROP_KINDS.map(String::from).to_vec(), - } -} - -/// Terrain lattice and props of the context's cell (the page's chunk). -#[game_pure(GridCellFn)] -pub fn grid_cell(cell: &GridContext) -> GridCellContent { - let at = (cell.cx, cell.cy); - let n = SAMPLES * SAMPLES; - let mut content = GridCellContent { - heights: Vec::with_capacity(n), - moisture: Vec::with_capacity(n), - biomes: Vec::with_capacity(n), - props: cell_props(cell.seed, at), - }; - for j in 0..SAMPLES { - for i in 0..SAMPLES { - let l = (i as f32 * SAMPLE_SPACING, j as f32 * SAMPLE_SPACING); - let h = height(cell.seed, at, l); - let m = moisture(cell.seed, at, l); - content.heights.push(h); - content.moisture.push(m); - content.biomes.push(biome_of(h, m)); - } - } - content -} - -/// Terrain at one point of a cell (coordinates clamped into the cell). -#[game_pure(GridSampleFn)] -pub fn grid_sample(point: &GridPoint) -> GridSample { - let at = (point.cx, point.cy); - let l = (point.x.clamp(0.0, CELL), point.y.clamp(0.0, CELL)); - let height = height(point.seed, at, l); - let moisture = moisture(point.seed, at, l); - GridSample { - height, - moisture, - biome: biome_of(height, moisture), - } -} - -/// Neighbour a walker at `(x, y)` belongs to, as a cell offset, or None -/// while inside (with hysteresis). X first, then y. -pub fn crossing(x: f32, y: f32) -> Option<(i32, i32)> { - if x < -HYSTERESIS { - Some((-1, 0)) - } else if x > CELL + HYSTERESIS { - Some((1, 0)) - } else if y < -HYSTERESIS { - Some((0, -1)) - } else if y > CELL + HYSTERESIS { - Some((0, 1)) - } else { - None - } -} - -/// `grid..`; coordinates are limited to ±1 000 000. -pub fn zone_id(cell: (i32, i32)) -> String { - format!("grid.{}.{}", cell.0, cell.1) -} - -/// Largest |cell coordinate|. -pub const MAX_CELL: i32 = 1_000_000; - -/// Parse `grid..` (canonical integers only). -pub fn parse_zone(zone_id: &str) -> Option<(i32, i32)> { - let rest = zone_id.strip_prefix("grid.")?; - let (x, y) = rest.split_once('.')?; - let parse = |s: &str| -> Option { - let v: i32 = s.parse().ok()?; - (v.to_string() == s && v.abs() <= MAX_CELL).then_some(v) - }; - Some((parse(x)?, parse(y)?)) -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn zone_ids_round_trip() { - assert_eq!(parse_zone("grid.0.0"), Some((0, 0))); - assert_eq!(parse_zone("grid.-3.12"), Some((-3, 12))); - assert_eq!(parse_zone(&zone_id((-7, 9))), Some((-7, 9))); - for bad in [ - "grid.1", - "grid.01.2", - "grid.+1.2", - "grid.1.2.3", - "grid.2000000.0", - "arena-1", - ] { - assert_eq!(parse_zone(bad), None, "{bad}"); - } - } - #[test] fn borders_match_and_trails_stay_free() { let seed = DEFAULT_SEED; @@ -598,32 +286,12 @@ mod tests { for line in [0.0, 8.0, 16.0, 24.0, 32.0] { assert!((p.x - line).abs() > reach && (p.y - line).abs() > reach); } - // A walker up to HYSTERESIS past the border (still - // owned by this cell) never overlaps a neighbour's prop. - let clear = HYSTERESIS + PLAYER_RADIUS; + // Props keep clear of the cell border. + let clear = 0.5 + PLAYER_RADIUS; assert!(p.x - p.r >= clear && p.x + p.r <= CELL - clear); assert!(p.y - p.r >= clear && p.y + p.r <= CELL - clear); } } } } - - #[test] - fn walking_a_trail_crosses_the_cell() { - let props = cell_props(DEFAULT_SEED, (0, 0)); - let (mut x, mut y) = (16.0, 16.0); - let east = MoveInput { - mx: 127, - my: 0, - buttons: BUTTON_SPRINT, - }; - let mut frames = 0; - while crossing(x, y).is_none() { - (x, y) = step(x, y, east, &props); - frames += 1; - assert!(frames < 200, "stuck at {x},{y}"); - } - assert_eq!(crossing(x, y), Some((1, 0))); - assert!((y - 16.0).abs() < 1e-3); - } } diff --git a/examples/game/server/sim/src/lib.rs b/examples/game/server/sim/src/lib.rs index 0509a774..38488717 100644 --- a/examples/game/server/sim/src/lib.rs +++ b/examples/game/server/sim/src/lib.rs @@ -9,16 +9,14 @@ //! //! | step | kind | used by | //! |---|---|---| -//! | [`arena::ArenaStep`] | `player` | Arena, The Freeze | +//! | [`arena::ArenaStep`] | `player` | The Freeze | //! | [`mobpit::PitStep`] | `player` | Mob Pit | //! | [`ripple::HoodStep`] | `player` | Boss Ripple (hoods) | -//! | [`grid::GridStep`] | `walker` | Endless Grid (context: seed + cell) | //! -//! The Endless Grid's world generation is also compiled for the page: -//! [`grid::grid_cell`] and [`grid::grid_sample`] are `#[game_pure]` -//! functions (terrain lattice, biomes, props), so the browser renders the -//! server's own content. Static data the pages draw is exported as -//! surface constants ([`arena::arena_floor`], [`grid::grid_world`]). +//! The seeded world generation ([`grid`]: noise, biomes, props) feeds Mob +//! Pit's rooms; [`mobpit::pit_room`] is a `#[game_pure]` function, so the +//! browser draws the server's own rooms. Static data the pages draw is exported as +//! surface constants ([`arena::arena_floor`], [`mobpit::pit_world`]). //! //! Nothing here may depend on server-only crates (Tokio, axum, SQLite): //! the crate must compile for `wasm32-unknown-unknown`. diff --git a/examples/game/server/src/grid/dev.rs b/examples/game/server/src/grid/dev.rs deleted file mode 100644 index 8dda2e93..00000000 --- a/examples/game/server/src/grid/dev.rs +++ /dev/null @@ -1,211 +0,0 @@ -//! **Dev-only** world map routes of the grid (mounted with `--dev`). -//! -//! - `GET /dev/grid/map?x=0&y=0&r=4` (any host): every cell within `r` -//! (≤ 8) of `(x, y)` with its owning host and state — `active` (with -//! tier, players, observers from the host running it), `hibernated` (a -//! snapshot is stored) or `cold` (never ran, or ran without being -//! hibernated). Live details of cells owned by a peer are answered by -//! that peer (`/dev/grid/local`); everything else comes from the zone -//! status read model (`crate::common::zone_status`), never from the -//! cells (reading a never-used cell would create it). -//! - `POST /dev/grid/local {zones: [...]}` (admin secret, host to host): -//! live details of this host's active cells among `zones`. - -use std::sync::Arc; -use std::time::Duration; - -use axum::extract::State; -use axum::http::{HeaderMap, StatusCode, Uri}; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use distributed_game::net::GameServer; -use distributed_game::world::zone_status::ZoneStatus; -use distributed_game::Tier; -use serde_json::{json, Value}; - -use super::{world, GridZone}; -use crate::cluster::World; - -/// Largest map radius (cells). -pub const MAX_RADIUS: i32 = 8; - -#[derive(Clone)] -struct MapState { - server: GameServer, - world: Arc, -} - -/// The router. -pub fn routes(server: GameServer, world: Arc) -> Router { - Router::new() - .route("/dev/grid/map", get(map)) - .route("/dev/grid/local", post(local)) - .with_state(MapState { server, world }) -} - -fn query_i32(uri: &Uri, key: &str, default: i32) -> i32 { - uri.query() - .unwrap_or_default() - .split('&') - .filter_map(|pair| pair.split_once('=')) - .find(|(k, _)| *k == key) - .and_then(|(_, v)| v.parse().ok()) - .unwrap_or(default) -} - -fn tier_name(tier: Tier) -> &'static str { - match tier { - Tier::Hot => "hot", - Tier::Idle => "idle", - Tier::Dormant => "dormant", - } -} - -/// Live details of this host's active cells among `zones`, keyed by zone -/// id. -async fn local_cells(state: &MapState, zones: &[String]) -> serde_json::Map { - let mut out = serde_json::Map::new(); - for zone in zones { - if state.world.owner(zone) != state.world.host.id - || world::parse_zone(zone).is_none() - || !state.server.is_active(zone) - { - continue; - } - let inspect = state.server.inspect(zone, |h| { - (tier_name(h.tier()), h.player_count(), h.observer_count()) - }); - if let Ok(Some((tier, players, observers))) = - tokio::time::timeout(Duration::from_millis(300), inspect).await - { - out.insert( - zone.clone(), - json!({ - "state": "active", "tier": tier, "players": players, "observers": observers - }), - ); - } - } - out -} - -/// The read model's state of every zone not live on a host. -async fn stored_cells(state: &MapState, zones: &[String]) -> serde_json::Map { - let mut out = serde_json::Map::new(); - let Some(view) = &state.world.zone_status else { - return out; - }; - match view.statuses(zones).await { - Ok(statuses) => { - for (zone, status) in statuses { - let fact = match status { - ZoneStatus::Active { holder } => json!({ "state": "active", "holder": holder }), - ZoneStatus::Hibernated { bytes } => { - json!({ "state": "hibernated", "bytes": bytes }) - } - ZoneStatus::Dormant => json!({ "state": "dormant" }), - ZoneStatus::Cold => json!({ "state": "cold" }), - }; - out.insert(zone, fact); - } - } - Err(error) => eprintln!("grid map: zone status: {error}"), - } - out -} - -async fn map(State(state): State, uri: Uri) -> Json { - let x = query_i32(&uri, "x", 0).clamp(-world::MAX_CELL, world::MAX_CELL); - let y = query_i32(&uri, "y", 0).clamp(-world::MAX_CELL, world::MAX_CELL); - let r = query_i32(&uri, "r", 4).clamp(0, MAX_RADIUS); - let mut cells = Vec::new(); - for cy in (y - r)..=(y + r) { - for cx in (x - r)..=(x + r) { - if cx.abs() <= world::MAX_CELL && cy.abs() <= world::MAX_CELL { - cells.push((cx, cy, world::zone_id((cx, cy)))); - } - } - } - let zones: Vec = cells.iter().map(|(_, _, z)| z.clone()).collect(); - let mut facts = stored_cells(&state, &zones).await; - facts.extend(local_cells(&state, &zones).await); - let mut totals = serde_json::Map::new(); - totals.insert( - state.world.host.id.clone(), - json!(state.server.zone_ids().len()), - ); - for (peer, reply) in state - .world - .ask_peers("/dev/grid/local", &json!({ "zones": zones })) - .await - { - if let Some(cells) = reply["cells"].as_object() { - facts.extend(cells.clone()); - } - totals.insert(peer, reply["activeTotal"].clone()); - } - let count = |host: &str, wanted: &str| { - zones - .iter() - .filter(|z| { - state.world.owner(z) == host - && facts.get(*z).and_then(|f| f["state"].as_str()) == Some(wanted) - }) - .count() - }; - let hosts: Vec = state - .world - .hosts - .iter() - .map(|host| { - json!({ - "id": host.id, - "wsUrl": host.ws_url, - "reachable": totals.contains_key(&host.id), - "activeTotal": totals.get(&host.id).cloned().unwrap_or(Value::Null), - "active": count(&host.id, "active"), - "hibernated": count(&host.id, "hibernated"), - }) - }) - .collect(); - let cells: Vec = cells - .iter() - .map(|(cx, cy, zone)| { - let mut cell = facts - .get(zone) - .cloned() - .unwrap_or_else(|| json!({ "state": "unknown" })); - cell["x"] = json!(cx); - cell["y"] = json!(cy); - cell["zone"] = json!(zone); - cell["owner"] = json!(state.world.owner(zone)); - cell - }) - .collect(); - Json(json!({ "host": state.world.host.id, "hosts": hosts, "cells": cells })) -} - -async fn local( - State(state): State, - headers: HeaderMap, - Json(body): Json, -) -> Result, StatusCode> { - if !state.world.authorized(&headers) { - return Err(StatusCode::UNAUTHORIZED); - } - let zones: Vec = body["zones"] - .as_array() - .map(|a| { - a.iter() - .filter_map(|z| z.as_str().map(str::to_string)) - .take(((2 * MAX_RADIUS + 1) * (2 * MAX_RADIUS + 1)) as usize) - .collect() - }) - .unwrap_or_default(); - let cells = local_cells(&state, &zones).await; - Ok(Json(json!({ - "host": state.world.host.id, - "activeTotal": state.server.zone_ids().len(), - "cells": cells, - }))) -} diff --git a/examples/game/server/src/grid/mod.rs b/examples/game/server/src/grid/mod.rs deleted file mode 100644 index d778fd50..00000000 --- a/examples/game/server/src/grid/mod.rs +++ /dev/null @@ -1,742 +0,0 @@ -//! **Endless Grid** — an unbounded world of square cells, one zone per cell. -//! -//! Mechanics on display: -//! -//! - **Seeded procedural content** ([`world`]): terrain, biomes and props of -//! a cell are a pure function of `(seed, cell, CONTENT_VERSION)`, -//! generated identically by the server (collision) and the client -//! (rendering); nothing static is replicated. -//! - **Spatial cells as zones**: zone `grid..` simulates the walkers -//! in its 32 m square plus a few wandering critters. Walking past a -//! border (with [`world::HYSTERESIS`]) hands the walker off to the -//! neighbour (`ctx.handoff`, carrying its position in the target's -//! frame and its name); the client follows the Handoff. -//! - **Interest across borders**: clients observe the 8 neighbouring zones -//! (observer connections), so critters and walkers across a border are -//! visible before you cross. -//! - **Cross-host handoff**: zones are placed on hosts by a consistent-hash -//! ring ([`HashRing`]); a Hello for a foreign zone gets `Redirect`, a -//! handoff into it reserves the transfer ticket with the issuing host. -//! - **Hibernation**: a zone nobody is in or observing turns Dormant, then -//! hibernates into its `zone-state/{zone}` celld cell (critter positions -//! and dropped markers; large snapshots in the cell's R2 bucket) and -//! restores on the next activation; its hello says `restored`. -//! -//! Zone ids: `grid..` with canonical integers, |x|, |y| ≤ 1 000 000. -//! The dots are not a hierarchy: the server uses `FlatTopology` (the -//! default `DottedTopology` would make `grid.3` the parent of `grid.3.5`). -//! The ring hashes the full id, so neighbouring cells land on either host. -//! -//! | kind | ids | fields | notes | -//! |---|---|---|---| -//! | 1 `walker` | 1000 + player | x y name hue | local coordinates | -//! | 2 `critter` | 1.. | x y yaw hue | wander, persisted on hibernation | -//! | 3 `marker` | 500.. | x y label hue | dropped with E, persisted | -//! -//! The kinds are `#[derive(Replicated)]` views, the hello a struct-form -//! `#[derive(GameEvent)]` ([`GridHello`]); the browser client is generated -//! from [`game_surface`]. -//! -//! Every entity is global within its zone (a cell is small); interest -//! between zones is the client's choice of observed neighbours. - -pub mod dev; -/// The procedural world, the walker step and its `#[predict]` declaration. -pub use game_sim::grid as world; - -use std::collections::BTreeMap; -use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; - -use distributed_game::net::{GameServer, ServerConfig, ZoneFactory}; -use distributed_game::world::{FlatTopology, HashRing, HostAddr, ZoneDirectory}; -use distributed_game::{ - schema, ConsumePolicy, Ctx, EntitySchema, GameEvent, GameInput, GameSurface, LeaveReason, - ObserverId, PlayerId, Replicated, WorldView, Zone, -}; -use serde::{Deserialize, Serialize}; - -use crate::cluster::{ClusterDemo, World}; -use crate::common::{Demo, Guests}; -use world::{MoveInput, Prop, CELL, POS}; - -/// A walker (a player), in cell-local coordinates. -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "walker", id = 1, priority = 2.0, global)] -pub struct WalkerView { - /// Local x. - #[field_type(POS)] - #[correct(ease = 120)] - pub x: f32, - /// Local y. - #[field_type(POS)] - #[correct(ease = 120)] - pub y: f32, - /// Display name. - #[priority(0.2)] - #[correct(replace)] - pub name: String, - /// Colour. - #[correct(replace)] - pub hue: u8, -} - -/// A wandering critter (persisted on hibernation). -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "critter", id = 2, global)] -pub struct CritterView { - /// Local x. - #[field_type(POS)] - pub x: f32, - /// Local y. - #[field_type(POS)] - pub y: f32, - /// Heading. - #[angle(bits = 8)] - pub yaw: f32, - /// Colour. - pub hue: u8, -} - -/// A dropped marker (persisted on hibernation). -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "marker", id = 3, global)] -pub struct MarkerView { - /// Local x. - #[field_type(POS)] - pub x: f32, - /// Local y. - #[field_type(POS)] - pub y: f32, - /// Who dropped it. - pub label: String, - /// Colour. - pub hue: u8, -} - -/// Type id of [`GridHello`]. -pub const EVENT_HELLO: u32 = GridHello::EVENT_ID; - -/// The grid's client surface. -pub fn game_surface() -> GameSurface { - GameSurface::for_zone::("grid") - .events::() - .predict::() - .pure::() - .pure::() - .constant("grid_world", &world::grid_world()) -} -/// Most markers a cell keeps (oldest dropped first). -pub const MAX_MARKERS: usize = 12; -/// Most command frames a walker may bank. -pub const MAX_CREDITS: u8 = 4; -const CRITTER_SPEED: f32 = 1.6; -const CRITTER_RADIUS: f32 = 0.3; -const MARK_COOLDOWN: u8 = 15; - -/// What a zone tells each player / observer on arrival. -#[derive(Debug, Clone, PartialEq, GameEvent)] -#[game_event(id = 1, name = "hello")] -pub struct GridHello { - /// Zone id. - pub zone: String, - /// Cell coordinates `[x, y]`. - pub cell: Vec, - /// Host running the zone. - pub host: String, - /// World seed (the client generates the same content). - pub seed: u32, - /// Generation version. - pub content_version: u32, - /// Cell edge (m). - pub cell_size: f32, - /// The player's own walker entity (players only). - pub entity: Option, - /// This activation restored a hibernation snapshot. - pub restored: bool, - /// Times this cell was restored (persisted). - pub restores: u32, - /// How long the snapshot slept (ms), when restored. - pub hibernated_for_ms: Option, - /// Critters / markers in the restored snapshot. - pub restored_critters: u32, - /// Markers in the restored snapshot. - pub restored_markers: u32, - /// Activation start (Unix ms): identifies this activation. - pub activated_at: u64, -} - -struct Walker { - entity: u32, - name: String, - hue: u8, - x: f32, - y: f32, - credits: u8, - mark_cooldown: u8, - leaving: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -struct Critter { - id: u32, - x: f32, - y: f32, - tx: f32, - ty: f32, - yaw: f32, - hue: u8, - rest: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -struct Marker { - id: u32, - x: f32, - y: f32, - label: String, - hue: u8, -} - -/// Hibernation snapshot (JSON). -#[derive(Debug, Serialize, Deserialize)] -struct Snapshot { - critters: Vec, - markers: Vec, - next_marker: u32, - restores: u32, - saved_at_ms: u64, -} - -/// Zone construction parameters. -#[derive(Clone)] -pub struct GridConfig { - /// Display names. - pub guests: Guests, - /// Host running the zone (shown to clients). - pub host_id: String, - /// World seed. - pub seed: u32, - /// The cell. - pub cell: (i32, i32), -} - -/// One cell of the grid. -pub struct GridZone { - config: GridConfig, - props: Vec, - walkers: BTreeMap, - critters: Vec, - markers: Vec, - next_marker: u32, - restores: u32, - restored: Option<(u64, u32, u32)>, - activated_at: u64, -} - -fn unix_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, |d| d.as_millis() as u64) -} - -fn hue_of(principal: &str) -> u8 { - let h = principal.bytes().fold(0x811c_9dc5u32, |h, b| { - (h ^ u32::from(b)).wrapping_mul(0x0100_0193) - }); - (world::hash32(h) >> 24) as u8 -} - -impl GridZone { - /// Walkers as `(name, x, y)` (ops tooling and tests). - pub fn walkers(&self) -> Vec<(String, f32, f32)> { - self.walkers - .values() - .map(|w| (w.name.clone(), w.x, w.y)) - .collect() - } - - /// Dropped markers. - pub fn marker_count(&self) -> usize { - self.markers.len() - } - - fn free_point(&self, ctx: &mut Ctx<'_, Self>, margin: f32) -> (f32, f32) { - for _ in 0..32 { - let x = 1.0 + ctx.rng().next_f32() * (CELL - 2.0); - let y = 1.0 + ctx.rng().next_f32() * (CELL - 2.0); - let clear = self - .props - .iter() - .filter(|p| p.solid) - .all(|p| (x - p.x).powi(2) + (y - p.y).powi(2) > (p.r + margin).powi(2)); - if clear { - return (x, y); - } - } - // Sub-cell corners are always free. - (CELL / 2.0, CELL / 2.0) - } - - fn hello(&self, ctx: &Ctx<'_, Self>, entity: Option) -> GridHello { - GridHello { - zone: ctx.zone_id().to_string(), - cell: vec![self.config.cell.0, self.config.cell.1], - host: self.config.host_id.clone(), - seed: self.config.seed, - content_version: world::CONTENT_VERSION, - cell_size: CELL, - entity, - restored: self.restored.is_some(), - restores: self.restores, - hibernated_for_ms: self.restored.map(|(ms, _, _)| ms), - restored_critters: self.restored.map_or(0, |(_, c, _)| c), - restored_markers: self.restored.map_or(0, |(_, _, m)| m), - activated_at: self.activated_at, - } - } - - fn admit( - &mut self, - ctx: &mut Ctx<'_, Self>, - player: PlayerId, - at: (f32, f32), - name: Option, - ) { - let principal = ctx - .player(player) - .map(|p| p.principal.clone()) - .unwrap_or_default(); - let name = self - .config - .guests - .name(&principal) - .or(name) - .unwrap_or_else(|| format!("Guest {}", principal.get(6..10).unwrap_or(""))); - let entity = 1000 + player.0; - self.walkers.insert( - player, - Walker { - entity, - name, - hue: hue_of(&principal), - x: world::snap(at.0), - y: world::snap(at.1), - credits: MAX_CREDITS, - mark_cooldown: 0, - leaving: false, - }, - ); - let hello = self.hello(ctx, Some(entity)); - ctx.send(player, &hello); - } - - fn drop_marker(&mut self, x: f32, y: f32, label: String, hue: u8) { - if self.markers.len() >= MAX_MARKERS { - self.markers.remove(0); - } - let id = 500 + self.next_marker % 400; - self.next_marker = self.next_marker.wrapping_add(1); - self.markers.push(Marker { - id, - x, - y, - label, - hue, - }); - } - - fn wander(&mut self, ctx: &mut Ctx<'_, Self>) { - let dt = ctx.dt_secs(); - for i in 0..self.critters.len() { - let c = &self.critters[i]; - if c.rest > 0 { - self.critters[i].rest -= 1; - continue; - } - let (ox, oy) = (c.tx - c.x, c.ty - c.y); - let dist = (ox * ox + oy * oy).sqrt(); - if dist < 0.2 { - let (tx, ty) = self.free_point(ctx, 0.8); - let rest = 10 + (ctx.rng().next_f32() * 50.0) as u16; - let c = &mut self.critters[i]; - (c.tx, c.ty, c.rest) = (tx, ty, rest); - continue; - } - let stride = (CRITTER_SPEED * dt).min(dist); - let (x, y) = world::collide( - c.x + ox / dist * stride, - c.y + oy / dist * stride, - CRITTER_RADIUS, - &self.props, - ); - let c = &mut self.critters[i]; - c.yaw = oy.atan2(ox); - (c.x, c.y) = ( - world::snap(x.clamp(0.3, CELL - 0.3)), - world::snap(y.clamp(0.3, CELL - 0.3)), - ); - } - } -} - -/// Carried state of a handoff: position in the target frame and the name. -pub fn encode_carried(x: f32, y: f32, name: &str) -> Vec { - let mut out = Vec::with_capacity(8 + name.len()); - out.extend_from_slice(&x.to_le_bytes()); - out.extend_from_slice(&y.to_le_bytes()); - out.extend_from_slice(name.as_bytes()); - out -} - -/// Inverse of [`encode_carried`]; None for a malformed or out-of-range payload. -pub fn decode_carried(carried: &[u8]) -> Option<(f32, f32, String)> { - let x = f32::from_le_bytes(carried.get(0..4)?.try_into().ok()?); - let y = f32::from_le_bytes(carried.get(4..8)?.try_into().ok()?); - let range = -world::OUTSIDE_LIMIT..=CELL + world::OUTSIDE_LIMIT; - if !range.contains(&x) || !range.contains(&y) { - return None; - } - let name = std::str::from_utf8(carried.get(8..)?).ok()?; - Some((x, y, name.chars().take(16).collect())) -} - -impl Zone for GridZone { - type Config = GridConfig; - type Input = MoveInput; - type Timer = (); - type Message = distributed_game::NoMessages; - - fn schema() -> EntitySchema { - schema![WalkerView, CritterView, MarkerView] - } - - fn on_create(ctx: &mut Ctx<'_, Self>, config: GridConfig) -> Self { - let props = world::cell_props(config.seed, config.cell); - let count = 2 + world::mix(config.seed, 9, config.cell.0, config.cell.1) % 3; - let mut zone = Self { - config, - props, - walkers: BTreeMap::new(), - critters: Vec::new(), - markers: Vec::new(), - next_marker: 0, - restores: 0, - restored: None, - activated_at: unix_ms(), - }; - for id in 1..=count { - let (x, y) = zone.free_point(ctx, 0.8); - let (tx, ty) = zone.free_point(ctx, 0.8); - let hue = (ctx.rng().next_f32() * 255.0) as u8; - zone.critters.push(Critter { - id, - x: world::snap(x), - y: world::snap(y), - tx, - ty, - yaw: 0.0, - hue, - rest: 0, - }); - } - zone - } - - fn on_join(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId) { - // Sub-cell corners are always free of props. - self.admit(ctx, player, (CELL / 2.0, CELL / 2.0), None); - } - - fn on_handoff_join( - &mut self, - ctx: &mut Ctx<'_, Self>, - player: PlayerId, - _from: &str, - carried: Vec, - ) { - match decode_carried(&carried) { - Some((x, y, name)) => self.admit(ctx, player, (x, y), Some(name)), - None => self.admit(ctx, player, (CELL / 2.0, CELL / 2.0), None), - } - } - - /// A reloaded page (fresh ticket for a slot it still holds): keep the - /// walker where it is, greet the new client session. - fn on_rejoin(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId) { - match self.walkers.get(&player) { - Some(w) if !w.leaving => { - let hello = self.hello(ctx, Some(w.entity)); - ctx.send(player, &hello); - } - _ => { - self.walkers.remove(&player); - self.on_join(ctx, player); - } - } - } - - fn on_leave(&mut self, _ctx: &mut Ctx<'_, Self>, player: PlayerId, _reason: LeaveReason) { - self.walkers.remove(&player); - } - - fn on_observe(&mut self, ctx: &mut Ctx<'_, Self>, observer: ObserverId) { - let hello = self.hello(ctx, None); - ctx.send(observer, &hello); - } - - fn decode_input(&self, _player: PlayerId, payload: &[u8]) -> Option { - MoveInput::from_payload(payload) - } - - fn on_tick(&mut self, ctx: &mut Ctx<'_, Self>) { - let ids: Vec = self.walkers.keys().copied().collect(); - for id in ids { - let mut marks = Vec::new(); - { - let Some(w) = self.walkers.get_mut(&id) else { - continue; - }; - if w.leaving { - continue; - } - w.credits = (w.credits + 1).min(MAX_CREDITS); - w.mark_cooldown = w.mark_cooldown.saturating_sub(1); - while w.credits > 0 { - let Some(input) = ctx.take_inputs(id, ConsumePolicy::Next).pop() else { - break; - }; - let input = input.input; - // `world::grid_step` (the declared predicted step) with this - // cell's props, which the zone already holds. - (w.x, w.y) = world::step(w.x, w.y, input, &self.props); - w.credits -= 1; - if input.buttons & world::BUTTON_MARK != 0 && w.mark_cooldown == 0 { - w.mark_cooldown = MARK_COOLDOWN; - marks.push((w.x, w.y, w.name.clone(), w.hue)); - } - if world::crossing(w.x, w.y).is_some() { - break; - } - } - } - for (x, y, label, hue) in marks { - self.drop_marker(x, y, label, hue); - } - let w = &self.walkers[&id]; - let Some((dx, dy)) = world::crossing(w.x, w.y) else { - continue; - }; - let (cx, cy) = (self.config.cell.0 + dx, self.config.cell.1 + dy); - let target = world::zone_id((cx, cy)); - let carried = encode_carried(w.x - dx as f32 * CELL, w.y - dy as f32 * CELL, &w.name); - let inside = cx.abs() <= world::MAX_CELL && cy.abs() <= world::MAX_CELL; - let handed = inside && ctx.handoff(id, &target, carried).is_ok(); - let w = self.walkers.get_mut(&id).expect("walker"); - if handed { - w.leaving = true; - } else { - // Edge of the world (or no issuer): stay inside. - w.x = w.x.clamp(-world::HYSTERESIS, CELL + world::HYSTERESIS); - w.y = w.y.clamp(-world::HYSTERESIS, CELL + world::HYSTERESIS); - } - } - self.wander(ctx); - } - - fn replicate(&self, out: &mut WorldView) { - for w in &self.walkers { - let w = w.1; - let view = WalkerView { - x: w.x, - y: w.y, - name: w.name.clone(), - hue: w.hue, - }; - out.add(w.entity, &view); - } - for c in &self.critters { - let view = CritterView { - x: c.x, - y: c.y, - yaw: c.yaw, - hue: c.hue, - }; - out.add(c.id, &view).priority(0.5); - } - for m in &self.markers { - let view = MarkerView { - x: m.x, - y: m.y, - label: m.label.clone(), - hue: m.hue, - }; - out.add(m.id, &view); - } - } - - fn hibernate(&self) -> Option> { - serde_json::to_vec(&Snapshot { - critters: self.critters.clone(), - markers: self.markers.clone(), - next_marker: self.next_marker, - restores: self.restores, - saved_at_ms: unix_ms(), - }) - .ok() - } - - fn on_restore(&mut self, _ctx: &mut Ctx<'_, Self>, state: &[u8], _saved_version: &str) { - let Ok(snapshot) = serde_json::from_slice::(state) else { - return; - }; - self.restored = Some(( - unix_ms().saturating_sub(snapshot.saved_at_ms), - snapshot.critters.len() as u32, - snapshot.markers.len() as u32, - )); - self.critters = snapshot.critters; - self.markers = snapshot.markers; - self.next_marker = snapshot.next_marker; - self.restores = snapshot.restores + 1; - } -} - -/// Bot payload: direction → sprinting [`MoveInput`]. -pub fn encode_bot_input(dir: [f32; 2]) -> Vec { - let axis = |v: f32| (v.clamp(-1.0, 1.0) * 127.0).round() as i8; - MoveInput { - mx: axis(dir[0]), - my: axis(dir[1]), - buttons: world::BUTTON_SPRINT, - } - .to_payload() -} - -/// World seed: `DG_GRID_SEED` (decimal or 0x-hex) or [`world::DEFAULT_SEED`]. -pub fn seed_from_env() -> u32 { - std::env::var("DG_GRID_SEED") - .ok() - .and_then(|v| { - let v = v.trim(); - match v.strip_prefix("0x") { - Some(hex) => u32::from_str_radix(hex, 16).ok(), - None => v.parse().ok(), - } - }) - .unwrap_or(world::DEFAULT_SEED) -} - -/// Demo registration (`game-demos grid`). -pub struct GridDemo; - -impl Demo for GridDemo { - type Zone = GridZone; - const NAME: &'static str = "grid"; - const DEFAULT_ZONE: &'static str = "grid.0.0"; - - fn hosts_zone(zone_id: &str) -> bool { - world::parse_zone(zone_id).is_some() - } - - /// Single host (used by `common::spawn`); `game-demos grid` runs - /// [`crate::cluster::run_cluster`] instead. - fn server(config: ServerConfig, guests: Guests) -> GameServer { - let world = crate::cluster::World::standalone::( - "local", - std::time::Duration::from_secs(4), - guests, - ); - Self::cluster_server(config, &world) - } -} - -impl ClusterDemo for GridDemo { - fn directory(hosts: &[HostAddr]) -> Arc { - Arc::new(HashRing::with_hosts(64, hosts.iter().cloned())) - } - - fn cluster_server(mut config: ServerConfig, world: &World) -> GameServer { - let seed = seed_from_env(); - config.host.world_seed = u64::from(seed); - config.host.idle_tick_rate = Some(2); - config.host.max_observers = 64; - let guests = world.guests.clone(); - let host_id = world.host.id.clone(); - GameServer::new( - config, - ZoneFactory::sync(move |activation| { - let zone_id = activation.zone_id.as_str(); - world::parse_zone(zone_id).map(|cell| GridConfig { - guests: guests.clone(), - host_id: host_id.clone(), - seed, - cell, - }) - }), - ) - .with_topology(Arc::new(FlatTopology)) - } - - fn cluster_routes(server: GameServer, world: Arc) -> axum::Router { - dev::routes(server, world) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn carried_state_round_trips_and_is_bounded() { - let bytes = encode_carried(0.5, 31.0, "Ana"); - assert_eq!(decode_carried(&bytes), Some((0.5, 31.0, "Ana".into()))); - assert_eq!(decode_carried(&encode_carried(99.0, 1.0, "x")), None); - assert_eq!(decode_carried(&[1, 2, 3]), None); - } - - #[test] - fn a_reload_keeps_the_walker() { - use distributed_game::{HostConfig, ManualClock, Role, TicketClaims, Viewer, ZoneHost}; - let clock = ManualClock::new(); - let mut host = ZoneHost::::new( - "cell.0.0", - HostConfig::default(), - std::sync::Arc::new(clock.clone()), - GridConfig { - guests: Guests::default(), - host_id: "A".into(), - seed: 7, - cell: (0, 0), - }, - ); - let claims = TicketClaims::new("guest-ana", "cell.0.0", u64::MAX, "n1", Role::Player); - let joined = host.join(&claims, 0, "").expect("join"); - let Viewer::Player(player) = joined.viewer else { - panic!("player") - }; - for seq in 1..=20u32 { - let input = MoveInput { - mx: 127, - my: 60, - buttons: 0, - }; - host.push_input(joined.client, seq, seq, &input.to_payload()); - clock.advance(std::time::Duration::from_millis(33)); - host.advance(); - } - let at = |host: &ZoneHost| { - let w = &host.zone().walkers[&player]; - (w.x, w.y) - }; - let before = at(&host); - assert_ne!(before, (CELL / 2.0, CELL / 2.0), "walked"); - host.disconnect(joined.client, false); - let claims = TicketClaims::new("guest-ana", "cell.0.0", u64::MAX, "n2", Role::Player); - let again = host.join(&claims, 0, "").expect("rejoin"); - assert_eq!(again.viewer, Viewer::Player(player)); - clock.advance(std::time::Duration::from_millis(33)); - host.advance(); - assert_eq!(at(&host), before, "same walker"); - } -} diff --git a/examples/game/server/src/lib.rs b/examples/game/server/src/lib.rs index db0c7dd5..e6e3de2a 100644 --- a/examples/game/server/src/lib.rs +++ b/examples/game/server/src/lib.rs @@ -17,7 +17,6 @@ pub mod common; pub mod devcelld; pub mod freeze; pub mod gallery; -pub mod grid; pub mod mobpit; pub mod predict_trace; pub mod ripple; diff --git a/examples/game/server/src/main.rs b/examples/game/server/src/main.rs index c8308116..e8204146 100644 --- a/examples/game/server/src/main.rs +++ b/examples/game/server/src/main.rs @@ -5,7 +5,6 @@ use game_demos::cluster::{run_cluster, ClusterArgs}; use game_demos::common::{run, DemoArgs}; use game_demos::freeze::world::FreezeArgs; use game_demos::gallery::GalleryDemo; -use game_demos::grid::GridDemo; use game_demos::mobpit::{self, MobPitArgs}; use game_demos::ripple::RippleDemo; @@ -29,8 +28,6 @@ enum DemoCommand { Gallery(DemoArgs), /// World-version barrier across two server processes, safe-room reloads. Freeze(FreezeArgs), - /// Endless Grid: procedural cells as zones, handoff across hosts, hibernation. - Grid(ClusterArgs), /// Boss Ripple: realm > city > borough > hood, tier-up events across hosts. Ripple(ClusterArgs), /// Print native traces of every predicted step over recorded input @@ -45,7 +42,6 @@ async fn main() -> std::io::Result<()> { DemoCommand::Mobpit(args) | DemoCommand::Leaderboard(args) => mobpit::run(args).await, DemoCommand::Gallery(args) => run::(args).await, DemoCommand::Freeze(args) => game_demos::freeze::world::run(args).await, - DemoCommand::Grid(args) => run_cluster::(args).await, DemoCommand::Ripple(args) => run_cluster::(args).await, DemoCommand::PredictTrace => { println!("{}", game_demos::predict_trace::traces()); diff --git a/examples/game/server/src/predict_trace.rs b/examples/game/server/src/predict_trace.rs index e2082e79..4a215efb 100644 --- a/examples/game/server/src/predict_trace.rs +++ b/examples/game/server/src/predict_trace.rs @@ -16,10 +16,6 @@ use distributed_game::prediction::{step_bytes, PredictContext, PredictStep}; use distributed_game::pure::{call_bytes, GamePure}; use distributed_game::Wire; use game_sim::arena::{self, ArenaStep, MoveState}; -use game_sim::grid::{ - self, GridCellFn, GridContext, GridPoint, GridSampleFn, GridStep, WalkerState, BUTTON_SPRINT, - CELL, DEFAULT_SEED, -}; use game_sim::mobpit::PitStep; use game_sim::ripple::HoodStep; use serde_json::{json, Value}; @@ -163,88 +159,6 @@ fn capsule_streams(extra_bytes: usize) -> Vec { ] } -/// The densest of a few cells, so a walk bumps into props. -fn busy_cell(seed: u32) -> (i32, i32) { - (0..12) - .map(|i| (i, 2)) - .max_by_key(|c| { - grid::cell_props(seed, *c) - .iter() - .filter(|p| p.solid) - .count() - }) - .expect("cells") -} - -fn grid_context(cell: (i32, i32)) -> GridContext { - GridContext { - seed: DEFAULT_SEED, - cx: cell.0, - cy: cell.1, - } -} - -/// Head for each solid prop of the busiest cell in turn (pushing into it), -/// then walk off two borders. -fn prop_walk() -> Stream { - let cell = busy_cell(DEFAULT_SEED); - let context = grid_context(cell); - let props = grid::cell_props(DEFAULT_SEED, cell); - let mut state = WalkerState { x: 16.0, y: 16.0 }; - let targets: Vec<(f32, f32)> = props - .iter() - .filter(|p| p.solid) - .map(|p| (p.x, p.y)) - .chain([(40.0, 16.0), (16.0, -5.0)]) - .collect(); - let mut inputs = Vec::new(); - for (k, (tx, ty)) in targets.iter().enumerate() { - for _ in 0..60 { - let (dx, dy) = (tx - state.x, ty - state.y); - let l = (dx * dx + dy * dy).sqrt().max(1e-3); - let input = [ - (dx / l * 127.0) as i8 as u8, - if k % 3 == 0 { - 0x80 - } else { - (dy / l * 127.0) as i8 as u8 - }, - if k % 2 == 0 { BUTTON_SPRINT } else { 0 }, - ]; - state = grid::grid_step( - &state, - &::from_payload(&input) - .expect("valid"), - &context, - arena::DT, - ); - inputs.push(input.to_vec()); - } - } - Stream { - name: format!("props {},{}", cell.0, cell.1), - start: state_bytes(&WalkerState { x: 16.0, y: 16.0 }), - context: context.encode_context(), - inputs, - } -} - -fn grid_streams() -> Vec { - let mut streams = vec![prop_walk()]; - for (i, cell) in [(0, 0), (3, -7), (999_999, -1_000_000)] - .into_iter() - .enumerate() - { - streams.push(Stream { - name: format!("random {},{}", cell.0, cell.1), - start: state_bytes(&WalkerState { x: 16.0, y: 16.0 }), - context: grid_context(cell).encode_context(), - inputs: random(0x9e37_79b9 ^ i as u32, 1500, 0), - }); - } - streams -} - fn pure_trace(args: Vec>) -> Value { let calls: Vec = args .iter() @@ -256,8 +170,8 @@ fn pure_trace(args: Vec>) -> Value { json!({ "export": F::EXPORT, "calls": calls }) } -/// Cells for the content traces: around the origin, far away, the -/// coordinate limits (lattice wrap-around) and the random walk's cells. +/// Rooms for the content traces: around the origin, far away, the +/// coordinate limits and random ones. fn content_cells() -> Vec<(i32, i32)> { let mut cells = vec![ (0, 0), @@ -267,7 +181,6 @@ fn content_cells() -> Vec<(i32, i32)> { (-12, 5), (999_999, -1_000_000), (-1_000_000, 1_000_000), - busy_cell(DEFAULT_SEED), ]; let mut rng = Rng(0x5eed_cafe); for _ in 0..24 { @@ -277,50 +190,6 @@ fn content_cells() -> Vec<(i32, i32)> { cells } -/// `grid_cell` for every content cell, under two seeds. -fn grid_cell_args() -> Vec> { - let mut args = Vec::new(); - for seed in [DEFAULT_SEED, 0xdead_beef] { - for (cx, cy) in content_cells() { - args.push(GridContext { seed, cx, cy }.encode_context()); - } - } - args -} - -/// `grid_sample` on lattice points, borders, arbitrary floats and points -/// outside the cell (clamped). -fn grid_sample_args() -> Vec> { - let mut rng = Rng(0x0dd_ba11); - let mut args = Vec::new(); - for (cx, cy) in content_cells() { - let mut points: Vec<(f32, f32)> = vec![ - (0.0, 0.0), - (CELL, CELL), - (CELL, 7.25), - (16.0, 0.0), - (-3.0, 40.0), - ]; - for _ in 0..40 { - let f = |r: &mut Rng| (r.next() >> 8) as f32 / 16_777_216.0 * (CELL + 4.0) - 2.0; - points.push((f(&mut rng), f(&mut rng))); - } - for (x, y) in points { - args.push( - GridPoint { - seed: DEFAULT_SEED, - cx, - cy, - x, - y, - } - .encode_context(), - ); - } - } - args -} - /// `pit_room` for the content cells (as rooms), under two seeds. fn pit_room_args() -> Vec> { let mut args = Vec::new(); @@ -337,15 +206,12 @@ pub fn traces() -> Value { json!({ "abi": distributed_game::prediction::ABI_VERSION, "pures": [ - pure_trace::(grid_cell_args()), - pure_trace::(grid_sample_args()), pure_trace::(pit_room_args()), ], "steps": [ trace::(capsule_streams(0)), trace::(capsule_streams(0)), trace::(capsule_streams(1)), - trace::(grid_streams()), ], }) } @@ -354,53 +220,11 @@ pub fn traces() -> Value { mod tests { use super::*; - #[test] - fn grid_step_is_the_zone_step_against_the_cell_props() { - let cell = busy_cell(DEFAULT_SEED); - let props = grid::cell_props(DEFAULT_SEED, cell); - let mut zone = (16.0f32, 16.0f32); - let mut predicted = WalkerState { x: 16.0, y: 16.0 }; - for input in random(7, 400, 0) { - let input = ::from_payload(&input) - .expect("valid"); - zone = grid::step(zone.0, zone.1, input, &props); - predicted = grid::grid_step(&predicted, &input, &grid_context(cell), arena::DT); - assert_eq!((predicted.x, predicted.y), zone); - } - } - - #[test] - fn grid_cell_is_the_world_generation() { - let cell = busy_cell(DEFAULT_SEED); - let content = grid::grid_cell(&grid_context(cell)); - assert_eq!(content.props, grid::cell_props(DEFAULT_SEED, cell)); - assert_eq!(content.heights.len(), grid::SAMPLES * grid::SAMPLES); - let (i, j) = (5, 11); - let l = ( - i as f32 * grid::SAMPLE_SPACING, - j as f32 * grid::SAMPLE_SPACING, - ); - let k = j * grid::SAMPLES + i; - assert_eq!(content.heights[k], grid::height(DEFAULT_SEED, cell, l)); - assert_eq!(content.biomes[k], grid::biome(DEFAULT_SEED, cell, l)); - let sample = grid::grid_sample(&GridPoint { - seed: DEFAULT_SEED, - cx: cell.0, - cy: cell.1, - x: l.0, - y: l.1, - }); - assert_eq!( - (sample.height, sample.moisture, sample.biome), - (content.heights[k], content.moisture[k], content.biomes[k]) - ); - } - #[test] fn traces_cover_every_step_and_collide() { let traces = traces(); let steps = traces["steps"].as_array().unwrap(); - assert_eq!(steps.len(), 4); + assert_eq!(steps.len(), 3); for step in steps { for stream in step["streams"].as_array().unwrap() { assert_eq!( diff --git a/examples/game/server/tests/grid_smoke.rs b/examples/game/server/tests/grid_smoke.rs deleted file mode 100644 index 48c2d637..00000000 --- a/examples/game/server/tests/grid_smoke.rs +++ /dev/null @@ -1,310 +0,0 @@ -//! Endless Grid smoke tests: two in-process hosts (A, B) on a hash ring. -//! -//! - a walker crosses several cell borders, at least one of them between -//! hosts, following every Handoff (and Redirect) without losing its -//! session, and ends in the right zone; -//! - a cell hibernates into its celld `ZoneState` cell and restores -//! critters and markers; -//! - dev routes: world map across hosts, names shared with peers. - -mod support; - -use std::time::Duration; - -use distributed_game::{FieldValue, Role, ServerMessage}; -use distributed_game::{GameInput, Replicated}; -use game_demos::grid::world::{self, MoveInput, BUTTON_MARK, BUTTON_SPRINT}; -use game_demos::grid::{CritterView, GridDemo, GridHello, MarkerView}; -use serde_json::{json, Value}; -use support::{eventually, http, join, start_cluster, ticket, Client}; - -fn east(buttons: u8) -> Vec { - MoveInput { - mx: 127, - my: 0, - buttons: BUTTON_SPRINT | buttons, - } - .to_payload() -} - -fn f(fields: &[FieldValue], i: usize) -> f32 { - match fields[i] { - FieldValue::F(v) => v, - _ => f32::NAN, - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn walker_crosses_borders_and_hosts_without_losing_its_session() { - let cluster = start_cluster::(&["A", "B"], Duration::from_secs(4)).await; - let world = &cluster.hosts[0].world; - // Walk east along the y = 16 trail until we crossed a host boundary. - let owner = |x: i32| world.owner(&world::zone_id((x, 0))); - let last = (1..=16) - .find(|k| (0..*k).any(|i| owner(i) != owner(i + 1)) && *k >= 2) - .expect("a cross-host border within 16 cells"); - let base = cluster.host("A").demo.ws_url(); - - // Interest across the border: an observer of the east neighbour sees its critters. - let mut observer = join( - &base, - "grid.1.0", - &ticket("guest-eye", "grid.1.0", Role::Observer), - ) - .await - .unwrap(); - let hello = observer.wait_event::(|_| true).await; - assert_eq!(hello.host, owner(1)); - assert!(hello.entity.is_none(), "observers control nothing"); - observer - .until("critters replicated to the observer", |c| { - c.count(CritterView::KIND_ID) >= 2 - }) - .await; - - let mut client = join( - &base, - "grid.0.0", - &ticket("guest-walker", "grid.0.0", Role::Player), - ) - .await - .unwrap(); - let hello = client.wait_event::(|_| true).await; - let name = format!("Guest {}", &"guest-walker"[6..10]); - assert_eq!(hello.cell, [0, 0]); - assert!(hello.entity.is_some()); - - let mut zone = "grid.0.0".to_string(); - let mut handoffs = 0; - let mut cross_host = 0; - let deadline = tokio::time::Instant::now() + Duration::from_secs(45); - let target = world::zone_id((last, 0)); - loop { - assert!( - tokio::time::Instant::now() < deadline, - "walk timed out in {zone}" - ); - client.input(east(0)).await; - let mut handoff = None; - while let Some(msg) = client.next(Duration::from_millis(30)).await { - match msg { - ServerMessage::Handoff { - target_zone, - transfer_ticket, - } => { - handoff = Some((target_zone, transfer_ticket)); - break; - } - ServerMessage::Error { code, message } => panic!("error {code}: {message}"), - ServerMessage::Kick { message, .. } => panic!("kicked: {message}"), - _ => {} - } - } - if let Some((to, transfer)) = handoff { - let from_owner = world.owner(&zone); - let to_owner = world.owner(&to); - let mut next: Client = join(&base, &to, &transfer).await.expect("target admits"); - let hello = next.wait_event::(|_| true).await; - assert_eq!(hello.zone, to); - assert_eq!(hello.host, to_owner); - assert_eq!( - next.redirects > 0, - to_owner != "A", - "redirected to the owner" - ); - handoffs += 1; - if from_owner != to_owner { - cross_host += 1; - } - zone = to; - client = next; - continue; - } - if zone == target { - let owner = cluster.owner(&zone); - let walkers = owner - .demo - .server - .inspect(&zone, |h| h.zone().walkers()) - .await - .unwrap(); - if walkers.first().is_some_and(|(_, x, _)| *x > 4.0) { - assert_eq!(walkers.len(), 1); - assert_eq!(walkers[0].0, name, "same walker, name carried across hosts"); - assert!((walkers[0].2 - 16.0).abs() < 0.01, "stayed on the trail"); - break; - } - } - } - assert_eq!(handoffs, last, "one handoff per border"); - assert!(cross_host >= 1, "crossed between hosts"); - for x in 0..last { - let id = world::zone_id((x, 0)); - let walkers = cluster - .owner(&id) - .demo - .server - .inspect(&id, |h| h.zone().walkers().len()) - .await; - assert!( - matches!(walkers, Some(0) | None), - "{id} released the walker" - ); - } - cluster.stop().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn empty_cell_hibernates_and_restores_critters_and_markers() { - let cluster = start_cluster::(&["A", "B"], Duration::from_millis(300)).await; - let zone = "grid.5.5"; - let owner = cluster.owner(zone); - let base = cluster.host("A").demo.ws_url(); - - let mut client = join(&base, zone, &ticket("guest-marker", zone, Role::Player)) - .await - .unwrap(); - let hello = client.wait_event::(|_| true).await; - assert!(!hello.restored); - client.input(east(BUTTON_MARK)).await; - client - .until("marker replicated", |c| c.count(MarkerView::KIND_ID) == 1) - .await; - let saves_before = owner.demo.server.metrics().hibernation_saves("saved"); - client.leave().await; - drop(client); - - // The zone leaves the active set before its snapshot is written, so wait - // for the save itself. - eventually("hibernation save", || { - let server = owner.demo.server.clone(); - async move { - !server.is_active(zone) && server.metrics().hibernation_saves("saved") > saves_before - } - }) - .await; - let saved = owner - .world - .state - .load(zone) - .await - .unwrap() - .expect("snapshot stored in celld"); - let snapshot: Value = serde_json::from_slice(&saved.state).unwrap(); - assert_eq!(snapshot["markers"].as_array().unwrap().len(), 1); - let critters = snapshot["critters"].as_array().unwrap().clone(); - assert!(critters.len() >= 2); - - // The world map shows it hibernated (asked on the other host too), from - // the zone status read model once the cells' events are projected. - let other = if owner.world.host.id == "A" { "B" } else { "A" }; - let other_addr = cluster.host(other).demo.addr; - eventually("map shows hibernated", || async move { - let (status, map) = http(other_addr, "GET", "/dev/grid/map?x=5&y=5&r=0", None).await; - status == 200 && map["cells"][0]["state"] == "hibernated" - }) - .await; - let (_, map) = http(other_addr, "GET", "/dev/grid/map?x=5&y=5&r=0", None).await; - assert_eq!(map["cells"][0]["owner"], owner.world.host.id.as_str()); - - let mut again = join(&base, zone, &ticket("guest-marker", zone, Role::Player)) - .await - .unwrap(); - let hello = again.wait_event::(|_| true).await; - assert!(hello.restored); - assert_eq!(hello.restores, 1); - assert_eq!(hello.restored_markers, 1); - assert_eq!(hello.restored_critters as usize, critters.len()); - let n = critters.len(); - again - .until("restored entities replicated", |c| { - c.count(CritterView::KIND_ID) == n && c.count(MarkerView::KIND_ID) == 1 - }) - .await; - // Critters resume exactly where they slept (first delta after restore). - for c in &critters { - let id = c["id"].as_u64().unwrap() as u32; - let fields = &again.mirror.entities[&id].fields; - let (x, y) = ( - c["x"].as_f64().unwrap() as f32, - c["y"].as_f64().unwrap() as f32, - ); - assert!( - (f(fields, 0) - x).abs() < 0.2 && (f(fields, 1) - y).abs() < 0.2, - "critter {id}" - ); - } - cluster.stop().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn dev_routes_share_names_and_map_both_hosts() { - let cluster = start_cluster::(&["A", "B"], Duration::from_secs(4)).await; - let world = &cluster.hosts[0].world; - let on_b = (0..64) - .map(|x| world::zone_id((x, 3))) - .find(|z| world.owner(z) == "B") - .unwrap(); - let a = cluster.host("A").demo.addr; - let (status, ticket) = http( - a, - "POST", - "/dev/ticket", - Some(json!({ "zone": on_b, "name": "Ana" })), - ) - .await; - assert_eq!(status, 200, "{ticket}"); - let principal = ticket["principal"].as_str().unwrap(); - assert_eq!( - cluster.host("B").demo.guests.name(principal).as_deref(), - Some("Ana") - ); - - // The ticket from A works on B (shared secret) and B greets by name. - let mut client = join( - &cluster.host("A").demo.ws_url(), - &on_b, - ticket["ticket"].as_str().unwrap(), - ) - .await - .unwrap(); - assert_eq!(client.redirects, 1); - client.wait_event::(|_| true).await; - let walkers = cluster - .host("B") - .demo - .server - .inspect(&on_b, |h| h.zone().walkers()) - .await - .unwrap(); - assert_eq!(walkers[0].0, "Ana"); - - let (status, map) = http(a, "GET", "/dev/grid/map?x=0&y=3&r=2", None).await; - assert_eq!(status, 200); - let cells = map["cells"].as_array().unwrap(); - assert_eq!(cells.len(), 25); - let owners: std::collections::BTreeSet<&str> = - cells.iter().filter_map(|c| c["owner"].as_str()).collect(); - assert_eq!(owners.len(), 2, "both hosts own cells nearby: {owners:?}"); - let mine = cells.iter().find(|c| c["zone"] == on_b.as_str()); - if let Some(cell) = mine { - assert_eq!(cell["state"], "active"); - assert_eq!(cell["players"], 1); - } - assert!(map["hosts"] - .as_array() - .unwrap() - .iter() - .all(|h| h["reachable"] == true)); - - // The peer route needs the admin secret. - let (status, _) = http( - cluster.host("B").demo.addr, - "POST", - "/dev/grid/local", - Some(json!({ "zones": [] })), - ) - .await; - assert_eq!(status, 401); - cluster.stop().await; -} diff --git a/examples/game/sveltekit/src/lib/pit/loop.ts b/examples/game/sveltekit/src/lib/pit/loop.ts index 65e618f4..fba0f136 100644 --- a/examples/game/sveltekit/src/lib/pit/loop.ts +++ b/examples/game/sveltekit/src/lib/pit/loop.ts @@ -45,6 +45,17 @@ function parseRoom(zone: string | undefined): [number, number] | undefined { return m === null ? undefined : [Number(m[1]), Number(m[2])]; } +/** The 8 rooms around `zone` (watched as passive observers: `watchZones`). */ +export function neighbourRooms(zone: string): string[] { + const at = parseRoom(zone); + if (at === undefined) return []; + const out: string[] = []; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) if (dx !== 0 || dy !== 0) out.push(`pit.${at[0] + dx}.${at[1] + dy}`); + } + return out; +} + /** * The own player's predicted fields in the next room's frame (the same world * point): the SDK keeps predicting through a doorway handoff (`handoffFrame`). @@ -237,6 +248,8 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) } } ctx.lineWidth = 1; + // Mobs drawn from the rooms next door (watched), for the headless test. + let nextDoor = 0; for (const entity of rendered) { const w = worldOf(entity); if (w === undefined) continue; @@ -245,6 +258,7 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) if (!pillars.some((q) => q.x === p.x && q.y === p.y)) pillars = [...pillars, { x: p.x, y: p.y, r: p.r }]; } else if (isMob(entity)) { if (entity.stale) continue; + if (entity.zoneId !== room) nextDoor += 1; const m = mobFields(entity); const look = TIER_RADIUS[m.tier]; if (m.tele > 0 && look !== undefined) circle(w.x, w.y, look.tele, 'rgba(255,80,80,0.12)', '#ff5050'); @@ -286,6 +300,7 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) ctx.fillText(p.name, px - 16, py - 0.7 * scale); } } + canvas.dataset.nextDoorMobs = String(nextDoor); frame = requestAnimationFrame(draw); }); diff --git a/examples/game/sveltekit/src/lib/server/game.ts b/examples/game/sveltekit/src/lib/server/game.ts index d2fe25dd..eb703fbd 100644 --- a/examples/game/sveltekit/src/lib/server/game.ts +++ b/examples/game/sveltekit/src/lib/server/game.ts @@ -47,6 +47,9 @@ export async function lastRoom(principal: string): Promise { */ export const tickets = createGameTicketHandler({ secret: () => env.GAME_TICKET_SECRET, + // The page also watches the 8 rooms next door (one observer ticket each + // per doorway, retried with back-off while a room sleeps). + rateLimit: { max: 120, windowMs: 60_000 }, getSession: (event: RequestEvent) => event.locals.auth(), zone: async ({ requested, principal }) => (isRoom(requested) ? requested : ((await lastRoom(principal)) ?? ENTRANCE)) }); diff --git a/examples/game/sveltekit/src/routes/pit/+page.svelte b/examples/game/sveltekit/src/routes/pit/+page.svelte index c69ca3cb..4ef928cf 100644 --- a/examples/game/sveltekit/src/routes/pit/+page.svelte +++ b/examples/game/sveltekit/src/routes/pit/+page.svelte @@ -1,10 +1,11 @@
@@ -139,6 +159,7 @@
pending inputs {net.pending} · ack {net.ack}
corrections {net.corrections} (last {net.last.toFixed(3)} m)
interest ring {hello.current?.viewRadius ?? '–'} m
+
next door {watching} watched · {asleep} asleep
diff --git a/examples/game/sveltekit/tests/pit.test.mjs b/examples/game/sveltekit/tests/pit.test.mjs index aebf117a..2fadffc4 100644 --- a/examples/game/sveltekit/tests/pit.test.mjs +++ b/examples/game/sveltekit/tests/pit.test.mjs @@ -293,7 +293,7 @@ test('sign in, join, move, kill, walk through rooms on celld, live read models f assert.equal((await fetch(`${ORIGIN}/api/dev/netsim`)).status, 401, 'signed-out: no network control'); await page.locator('[data-testid=latency]').fill('100'); await page.waitForSelector('[data-testid=netsim-status]:text-is("applied to every room")', { timeout: 5_000 }); - await page.waitForFunction((b) => Number(document.querySelector('[data-testid=rtt]')?.textContent) > b + 150, baseRtt, { timeout: 15_000 }); + await page.waitForFunction((b) => Number(document.querySelector("[data-testid=rtt]")?.textContent) > b + 120, baseRtt, { timeout: 20_000 }).catch(async (e) => { throw new Error(`rtt stayed ${await rtt()} (base ${baseRtt}): ${e}`); }); await page.keyboard.down('KeyA'); await page.waitForFunction(() => Number(document.querySelector('canvas')?.dataset.ghostGap ?? 0) > 0.3, null, { timeout: 5_000 }); await page.keyboard.up('KeyA'); @@ -362,6 +362,10 @@ test('sign in, join, move, kill, walk through rooms on celld, live read models f const eastNode = await nodeOf(page, 'pit.1.0'); assert.equal(await walkThrough(bob, 'north'), 'pit.0.1'); const northNode = await nodeOf(bob, 'pit.0.1'); + // Next door: Bob watches the 8 rooms around pit.0.1 (passively) and + // sees the mobs of the awake ones — pit.1.0, where Ana is. + await bob.waitForFunction(() => Number(document.querySelector('canvas')?.dataset.nextDoorMobs ?? 0) > 0, null, { timeout: 15_000 }); + await bob.waitForFunction(() => Number(document.querySelector('[data-testid=watching]')?.textContent) >= 1, null, { timeout: 15_000 }); // Bob reloads, then closes the browser and signs in again: the same // player, back in pit.0.1 where he stood (the room kept his body // through the reload; the ticket handler chose his last room). @@ -388,7 +392,8 @@ test('sign in, join, move, kill, walk through rooms on celld, live read models f assert.ok(fleet.nodes.includes(entranceNode) && fleet.nodes.includes(eastNode), `${entranceNode} ${eastNode}`); assert.notEqual(eastNode, entranceNode, 'the doorway led into a room on another celld node'); } - // The left room goes dormant, then asleep, live on both minimaps. + // The left room goes dormant, then asleep, live on both minimaps — + // although Bob, next door, watches it (a watcher keeps no room awake). await page.waitForSelector('[data-testid=room-pit\\.0\\.0][data-status=hibernated]', { timeout: 30_000 }); await bob.waitForSelector('[data-testid=room-pit\\.0\\.0][data-status=hibernated]', { timeout: 10_000 }); assert.equal(await roomStatus(page, 'pit.1.0'), 'active'); From 8530d4dde875fe8b7e49cf67501c4372a6dee9b3 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 21 Oct 2026 00:42:37 -0500 Subject: [PATCH 05/21] feat(game): CelldZone::refresh keeps a zone in step with its world A zone hosted as a celld object can declare `refresh_every` and `refresh(env, zone_id, round)`: async work off the tick (e.g. reading the cells of the tiers it belongs to) that runs on every wake and at that interval while the zone ticks; the message it returns is delivered into the zone. `round` counts calls per activation so slower tiers can be read less often. Refs [[tasks/gr-p44-mobpit-world]] --- distributed_game/README.md | 4 +- distributed_game/src/celld_zone/mod.rs | 4 + distributed_game/src/celld_zone/object.rs | 62 ++ examples/game/client/ripple/index.html | 13 - .../client/src/generated/ripple/client.ts | 68 -- .../client/src/generated/ripple/constants.ts | 39 - .../client/src/generated/ripple/events.ts | 195 ---- .../game/client/src/generated/ripple/index.ts | 9 - .../game/client/src/generated/ripple/input.ts | 55 -- .../client/src/generated/ripple/predict.ts | 51 - .../client/src/generated/ripple/schema.ts | 152 --- .../client/src/generated/ripple/surface.json | 684 -------------- examples/game/client/src/ripple/client.ts | 195 ---- examples/game/client/src/ripple/main.ts | 328 ------- examples/game/client/src/ripple/ripple.css | 148 --- .../game/client/tests/helpers/cluster.mjs | 90 -- .../client/tests/ripple-headless.test.mjs | 57 -- examples/game/server/sim/src/ripple.rs | 41 - examples/game/server/src/cluster.rs | 451 --------- examples/game/server/src/ripple/mod.rs | 894 ------------------ examples/game/server/tests/ripple_smoke.rs | 234 ----- examples/game/server/tests/support/mod.rs | 315 ------ 22 files changed, 67 insertions(+), 4022 deletions(-) delete mode 100644 examples/game/client/ripple/index.html delete mode 100644 examples/game/client/src/generated/ripple/client.ts delete mode 100644 examples/game/client/src/generated/ripple/constants.ts delete mode 100644 examples/game/client/src/generated/ripple/events.ts delete mode 100644 examples/game/client/src/generated/ripple/index.ts delete mode 100644 examples/game/client/src/generated/ripple/input.ts delete mode 100644 examples/game/client/src/generated/ripple/predict.ts delete mode 100644 examples/game/client/src/generated/ripple/schema.ts delete mode 100644 examples/game/client/src/generated/ripple/surface.json delete mode 100644 examples/game/client/src/ripple/client.ts delete mode 100644 examples/game/client/src/ripple/main.ts delete mode 100644 examples/game/client/src/ripple/ripple.css delete mode 100644 examples/game/client/tests/helpers/cluster.mjs delete mode 100644 examples/game/client/tests/ripple-headless.test.mjs delete mode 100644 examples/game/server/sim/src/ripple.rs delete mode 100644 examples/game/server/src/cluster.rs delete mode 100644 examples/game/server/src/ripple/mod.rs delete mode 100644 examples/game/server/tests/ripple_smoke.rs delete mode 100644 examples/game/server/tests/support/mod.rs diff --git a/distributed_game/README.md b/distributed_game/README.md index 39498300..1ccb7af4 100644 --- a/distributed_game/README.md +++ b/distributed_game/README.md @@ -101,12 +101,10 @@ their mechanic moves in): | Demo | Run | Shows | |---|---|---| -| Mob Pit | `examples/game/run.sh mobpit` (`--fleet` for several celld nodes) | rooms as celld objects joined by doorways, sleeping when empty and restored on return; combat whose rewards are saved exactly once without stalling; a network panel (simulated latency / jitter / loss on every room, prediction on/off with a server ghost, the interest ring) | +| Mob Pit | `examples/game/run.sh mobpit` (`--fleet` for several celld nodes) | rooms as celld objects joined by doorways, sleeping when empty and restored on return; combat whose rewards are saved exactly once without stalling; a network panel (simulated latency / jitter / loss on every room, prediction on/off with a server ghost, the interest ring); the rooms next door seen through passive watchers; hoods › boroughs › city as tier cells (boss kills ripple up through a saga, weather lifts as bosses fall) | | Leaderboard | `run.sh leaderboard` | a live ranking fed from saved kills, rebuilt from history | | Shooting Gallery | `run.sh gallery` | lag compensation on/off under latency | | The Freeze | `run.sh freeze` | pausing every zone on two servers, updating content, resuming; safe-room reloads | -| Endless Grid | `run.sh grid` | a generated world split into zones across two servers, seamless border crossings, zones sleeping and waking | -| Boss Ripple | `run.sh ripple` | nested zones (realm › city › borough › neighbourhood) with events travelling up | Then open `http://localhost:5173//`. Each page explains what to try. diff --git a/distributed_game/src/celld_zone/mod.rs b/distributed_game/src/celld_zone/mod.rs index cd495323..c0e1ab80 100644 --- a/distributed_game/src/celld_zone/mod.rs +++ b/distributed_game/src/celld_zone/mod.rs @@ -40,6 +40,10 @@ //! the object's outbox before it is sent and deleted when settled; the //! next activation or the outbox alarm resends what is left, and the //! cells' command ledger turns a resend into a replay: exactly once. +//! - **The world around.** [`CelldZone::refresh`] runs off the tick on +//! every wake and every [`CelldZone::refresh_every`] while the zone +//! ticks (e.g. reading the cells of the tiers a room belongs to); its +//! message is delivered into the zone. //! - **Status.** The zone reports its lifecycle as facts into its //! [`zone_activity`](crate::world::zone_activity) cell, projected into the //! [`zone_status`](crate::world::zone_status) read model (minimaps). diff --git a/distributed_game/src/celld_zone/object.rs b/distributed_game/src/celld_zone/object.rs index c447e111..69e2dbf2 100644 --- a/distributed_game/src/celld_zone/object.rs +++ b/distributed_game/src/celld_zone/object.rs @@ -107,6 +107,25 @@ pub trait CelldZone: Zone { ) -> Option>> { None } + + /// How often [`refresh`](Self::refresh) runs while the zone ticks + /// (`None`: never). It also runs once on every wake. + fn refresh_every() -> Option { + None + } + + /// Async work that keeps the zone in step with the world around it — + /// e.g. read the cells of the tiers it belongs to — off the tick; the + /// message it returns is delivered into the zone ([`Zone::on_message`]). + /// `round` counts the calls of this activation (0 on wake), so a zone + /// can read slower tiers less often. + fn refresh( + _env: &ObjectEnv, + _zone_id: &str, + _round: u64, + ) -> Option>> { + None + } } // ------------------------------------------------------------------ env @@ -411,6 +430,9 @@ struct Inner { /// Spawns a drain task waking at the given time (set by the object). drainer: Option>, last_dev_read_ms: f64, + /// [`CelldZone::refresh`] calls of this activation, and the last one. + refresh_round: u64, + last_refresh_ms: f64, } impl Inner { @@ -682,6 +704,8 @@ impl ZoneObject { drain_at: None, drainer: None, last_dev_read_ms: 0.0, + refresh_round: 0, + last_refresh_ms: 0.0, }; let inner = Rc::new(RefCell::new(inner)); let weak = Rc::downgrade(&inner); @@ -1061,6 +1085,7 @@ impl ZoneObject { inner.host = Some(host); inner.restored = restored; inner.wakes += 1; + inner.refresh_round = 0; // Each wake is a new activation: sockets of an earlier one are // told to rejoin (1012). if inner.wakes > 1 { @@ -1073,9 +1098,45 @@ impl ZoneObject { self.store_status().await; self.find_node(); self.read_dev_settings().await; + self.refresh(); Ok(()) } + /// Run [`CelldZone::refresh`] off the tick and deliver its message. + fn refresh(&self) { + let (zone_id, round) = { + let mut inner = self.inner.borrow_mut(); + inner.last_refresh_ms = now_ms(); + let round = inner.refresh_round; + inner.refresh_round += 1; + (inner.zone_id.clone(), round) + }; + let Some(work) = Z::refresh(&self.env, &zone_id, round) else { + return; + }; + let inner = self.inner.clone(); + wasm_bindgen_futures::spawn_local(async move { + if let Some(message) = work.await { + let mut inner = inner.borrow_mut(); + if let Some(host) = inner.host.as_mut() { + host.deliver(ZoneInbound::Message(message)); + inner.flush(); + } + } + }); + } + + /// [`refresh`](Self::refresh) when [`CelldZone::refresh_every`] is due. + fn refresh_if_due(&self) { + let Some(every) = Z::refresh_every() else { + return; + }; + let due = now_ms() - self.inner.borrow().last_refresh_ms >= every.as_millis() as f64; + if due { + self.refresh(); + } + } + /// Dev only (`ZONE_DEV_SETTINGS=1`): read the network simulation from /// the worker's dev settings object. async fn read_dev_settings(&self) { @@ -1344,6 +1405,7 @@ impl ZoneObject { }; self.spool().await; self.refresh_dev_settings(); + self.refresh_if_due(); if persist { self.persist().await; if !stop { diff --git a/examples/game/client/ripple/index.html b/examples/game/client/ripple/index.html deleted file mode 100644 index f98f2f69..00000000 --- a/examples/game/client/ripple/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Boss Ripple Demo - - - -
- - - diff --git a/examples/game/client/src/generated/ripple/client.ts b/examples/game/client/src/generated/ripple/client.ts deleted file mode 100644 index 089b3594..00000000 --- a/examples/game/client/src/generated/ripple/client.ts +++ /dev/null @@ -1,68 +0,0 @@ -// @generated by `distributed game-client` from game surface `ripple` (schema v3, protocol v1). -// Typed client factory. -// Do not edit: change the Rust definitions and regenerate. -import { GameClient, mergeCorrections, type GameClientOptions, type GamePredictionOptions, type WasmSource } from '@hops-ops/distributed/game'; - -import { EVENTS, type Events } from './events.ts'; -import { encodeInput, type HoodInput } from './input.ts'; -import { createRipplePrediction, type RipplePrediction } from './predict.ts'; -import { CORRECTIONS } from './schema.ts'; - -/** - * Prediction tuning; `encodeInput`, the correction defaults and `predict` (the - * compiled `#[predict]` step) are filled in. A `predict` given here replaces - * the WebAssembly step. - */ -export type RipplePredictionOptions = Omit, 'encodeInput' | 'predict'> & - Partial, 'encodeInput' | 'predict'>>; - -export type RippleClientOptions = Omit, 'prediction'> & - Readonly<{ - /** Prediction tuning, or `false` for none. Default: the compiled step. */ - prediction?: RipplePredictionOptions | false; - /** Where to load the compiled step from (default `PREDICT_WASM_URL`). */ - wasm?: WasmSource; - }>; - -export type RippleClient = Readonly<{ - client: GameClient; - /** The compiled step (`ready`, `setContext`); undefined when prediction is off or custom. */ - prediction: RipplePrediction | undefined; - /** Typed event subscription (every zone); returns the unsubscribe. */ - on(name: K, handler: (value: Events[K], zoneId: string) => void): () => void; - /** Send one input: predicted when prediction is on, raw otherwise. */ - send(input: HoodInput): number | undefined; -}>; - -/** - * A `GameClient` for the `ripple` surface with its input encoder, correction - * defaults and the compiled predicted step wired in. - */ -export function createRippleClient(options: RippleClientOptions): RippleClient { - const { prediction: tuning, wasm, ...rest } = options; - const prediction = - tuning === false || tuning?.predict !== undefined ? undefined : createRipplePrediction(wasm); - const client = new GameClient({ - ...rest, - prediction: - tuning === false - ? undefined - : { - ...tuning, - predict: tuning?.predict ?? prediction!.predict, - encodeInput: tuning?.encodeInput ?? ((input) => encodeInput(input)), - corrections: mergeCorrections(CORRECTIONS, tuning?.corrections) - } - }); - return { - client, - prediction, - on: (name, handler) => - client.on('event', (zoneId, event) => { - const codec = EVENTS[name]; - if (event.typeId === codec.id) handler(codec.decode(event.payload), zoneId); - }), - send: (input) => - tuning === false ? client.sendRawInput(encodeInput(input)) : client.sendInput(input) - }; -} diff --git a/examples/game/client/src/generated/ripple/constants.ts b/examples/game/client/src/generated/ripple/constants.ts deleted file mode 100644 index 6176587f..00000000 --- a/examples/game/client/src/generated/ripple/constants.ts +++ /dev/null @@ -1,39 +0,0 @@ -// @generated by `distributed game-client` from game surface `ripple` (schema v3, protocol v1). -// Constants: static data defined once in Rust (`GameSurface::constant`). -// Do not edit: change the Rust definitions and regenerate. -import type { ArenaFloor, RippleWorld } from './events.ts'; - -/** `arena_floor`, as the client decodes it. */ -export const ARENA_FLOOR: ArenaFloor = { - half: 20, - playerRadius: 0.5, - dashCooldown: 45, - pillars: [ - { x: 0, y: 0, r: 2.5 }, - { x: -9, y: -9, r: 1.5 }, - { x: 9, y: -9, r: 1.5 }, - { x: -9, y: 9, r: 1.5 }, - { x: 9, y: 9, r: 1.5 }, - { x: 0, y: -14, r: 1 }, - { x: 0, y: 14, r: 1 }, - { x: -14, y: 0, r: 1 }, - { x: 14, y: 0, r: 1 } - ] -}; - -/** `ripple_world`, as the client decodes it. */ -export const RIPPLE_WORLD: RippleWorld = { - realm: 'realm', - city: 'realm.city', - boroughs: [ - { - id: 'realm.city.borough-1', - hoods: ['realm.city.borough-1.hood-1', 'realm.city.borough-1.hood-2', 'realm.city.borough-1.hood-3'] - }, - { - id: 'realm.city.borough-2', - hoods: ['realm.city.borough-2.hood-1', 'realm.city.borough-2.hood-2', 'realm.city.borough-2.hood-3'] - } - ], - weather: ['storm', 'rain', 'clearing', 'clear'] -}; diff --git a/examples/game/client/src/generated/ripple/events.ts b/examples/game/client/src/generated/ripple/events.ts deleted file mode 100644 index 9ea2702d..00000000 --- a/examples/game/client/src/generated/ripple/events.ts +++ /dev/null @@ -1,195 +0,0 @@ -// @generated by `distributed game-client` from game surface `ripple` (schema v3, protocol v1). -// Events and records: payload interfaces, ids, codecs, typed subscriptions. -// Do not edit: change the Rust definitions and regenerate. -import { decodeGameEvent, decodePayload, decodeValue, encodePayload, encodeValue, onGameEvent, type DecodedGameEvent, type GameEventCodecs, type GameEventSource, type PayloadLayout, type WireType } from '@hops-ops/distributed/game'; - -/** Record `Pillar` (`#[derive(GameRecord)]`). */ -export interface Pillar { - readonly x: number; - readonly y: number; - readonly r: number; -} - -/** Record `ArenaFloor` (`#[derive(GameRecord)]`). */ -export interface ArenaFloor { - readonly half: number; - readonly playerRadius: number; - readonly dashCooldown: number; - readonly pillars: readonly Pillar[]; -} - -/** Record `BoroughLayout` (`#[derive(GameRecord)]`). */ -export interface BoroughLayout { - readonly id: string; - readonly hoods: readonly string[]; -} - -/** Record `MoveState` (`#[derive(GameRecord)]`). */ -export interface MoveState { - readonly x: number; - readonly y: number; - readonly vx: number; - readonly vy: number; - readonly dash: number; - readonly cd: number; -} - -/** Record `RippleWorld` (`#[derive(GameRecord)]`). */ -export interface RippleWorld { - readonly realm: string; - readonly city: string; - readonly boroughs: readonly BoroughLayout[]; - readonly weather: readonly string[]; -} - -/** Record `Weather` (`#[derive(GameRecord)]`). */ -export interface Weather { - readonly weather: string; - readonly remaining: number; -} - -const WEATHER_TYPE: WireType = { - type: 'record', - fields: [ - ['weather', { type: 'short_str' }], - ['remaining', { type: 'u32' }], - ] -}; - -/** Event `hello` (id 1). */ -export interface HelloEvent { - readonly zone: string; - readonly tier: string; - readonly parent: string | undefined; - readonly children: readonly string[]; - readonly host: string; - readonly tickRate: number; - readonly entity: number | undefined; - readonly weather: Weather | undefined; - readonly banner: string; -} - -const HELLO_LAYOUT: PayloadLayout = [ - ['zone', { type: 'short_str' }], - ['tier', { type: 'short_str' }], - ['parent', { type: 'optional', item: { type: 'short_str' } }], - ['children', { type: 'list', item: { type: 'short_str' } }], - ['host', { type: 'short_str' }], - ['tickRate', { type: 'u32' }], - ['entity', { type: 'optional', item: { type: 'u32' } }], - ['weather', { type: 'optional', item: WEATHER_TYPE }], - ['banner', { type: 'short_str' }], -]; - -/** Event `ripple` (id 2). */ -export interface RippleEvent { - readonly at: string; - readonly kind: string; - readonly from: string; - readonly origin: string; - readonly originTick: number; - readonly tick: number; -} - -const RIPPLE_LAYOUT: PayloadLayout = [ - ['at', { type: 'short_str' }], - ['kind', { type: 'short_str' }], - ['from', { type: 'short_str' }], - ['origin', { type: 'short_str' }], - ['originTick', { type: 'u64' }], - ['tick', { type: 'u64' }], -]; - -/** Event `weather` (id 3). */ -export type WeatherEvent = Weather; - -const WEATHER_PAYLOAD: WireType = WEATHER_TYPE; - -/** Event `banner` (id 4). */ -export interface BannerEvent { - readonly text: string; -} - -const BANNER_LAYOUT: PayloadLayout = [ - ['text', { type: 'short_str' }], -]; - -/** Event `boss` (id 5). */ -export interface BossEvent { - readonly kind: string; - readonly by: string | undefined; -} - -const BOSS_LAYOUT: PayloadLayout = [ - ['kind', { type: 'short_str' }], - ['by', { type: 'optional', item: { type: 'short_str' } }], -]; - -/** Event type ids by name. */ -export const EVENT = { - hello: 1, - ripple: 2, - weather: 3, - banner: 4, - boss: 5, -} as const; - -/** Event payload types by name. */ -export interface Events { - hello: HelloEvent; - ripple: RippleEvent; - weather: WeatherEvent; - banner: BannerEvent; - boss: BossEvent; -} - -/** Codecs of every event. */ -export const EVENTS: GameEventCodecs = { - hello: { - id: 1, - name: 'hello', - encode: (value) => encodePayload(HELLO_LAYOUT, value), - decode: (payload) => decodePayload(HELLO_LAYOUT, payload) as unknown as HelloEvent - }, - ripple: { - id: 2, - name: 'ripple', - encode: (value) => encodePayload(RIPPLE_LAYOUT, value), - decode: (payload) => decodePayload(RIPPLE_LAYOUT, payload) as unknown as RippleEvent - }, - weather: { - id: 3, - name: 'weather', - encode: (value) => encodeValue(WEATHER_PAYLOAD, value), - decode: (payload) => decodeValue(WEATHER_PAYLOAD, payload) as WeatherEvent - }, - banner: { - id: 4, - name: 'banner', - encode: (value) => encodePayload(BANNER_LAYOUT, value), - decode: (payload) => decodePayload(BANNER_LAYOUT, payload) as unknown as BannerEvent - }, - boss: { - id: 5, - name: 'boss', - encode: (value) => encodePayload(BOSS_LAYOUT, value), - decode: (payload) => decodePayload(BOSS_LAYOUT, payload) as unknown as BossEvent - }, -}; - -/** A decoded event, discriminated by `name`. */ -export type AnyEvent = DecodedGameEvent; - -/** Decode an Event message (`undefined` for ids this surface does not declare). */ -export function decodeEvent(event: { readonly typeId: number; readonly payload: Uint8Array }): AnyEvent | undefined { - return decodeGameEvent(EVENTS, event); -} - -/** Subscribe to one typed event on every zone of `client`; returns the unsubscribe. */ -export function onEvent( - client: GameEventSource, - name: K, - handler: (value: Events[K], zoneId: string) => void -): () => void { - return onGameEvent(client, EVENTS, name, handler); -} diff --git a/examples/game/client/src/generated/ripple/index.ts b/examples/game/client/src/generated/ripple/index.ts deleted file mode 100644 index 734c924a..00000000 --- a/examples/game/client/src/generated/ripple/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @generated by `distributed game-client` from game surface `ripple` (schema v3, protocol v1). -// Everything generated for this surface. -// Do not edit: change the Rust definitions and regenerate. -export * from './client.ts'; -export * from './constants.ts'; -export * from './events.ts'; -export * from './input.ts'; -export * from './predict.ts'; -export * from './schema.ts'; diff --git a/examples/game/client/src/generated/ripple/input.ts b/examples/game/client/src/generated/ripple/input.ts deleted file mode 100644 index 7280c9a4..00000000 --- a/examples/game/client/src/generated/ripple/input.ts +++ /dev/null @@ -1,55 +0,0 @@ -// @generated by `distributed game-client` from game surface `ripple` (schema v3, protocol v1). -// Input frame: interface, button bits, encoder, decoder, sanitize. -// Do not edit: change the Rust definitions and regenerate. -import { decodePayload, encodePayload, sanitizePayload, type InputRule, type PayloadLayout } from '@hops-ops/distributed/game'; - -/** One `HoodInput` input frame (`#[derive(GameInput)]`). */ -export interface HoodInput { - readonly mx: number; - readonly my: number; - readonly buttons: number; - readonly action: number; -} - -export type Input = HoodInput; - -/** Bits of each bit-flag field (`#[buttons(..)]`), by field. */ -export const BITS = { - buttons: { - dash: 1, - }, - action: { - attack: 1, - smite: 2, - }, -} as const; - -/** Wire layout, in field order. */ -export const INPUT_LAYOUT: PayloadLayout = [ - ['mx', { type: 'i8' }], - ['my', { type: 'i8' }], - ['buttons', { type: 'u8' }], - ['action', { type: 'u8' }], -]; - -const INPUT_RULES: readonly InputRule[] = [ - { range: [-127, 127] }, - { range: [-127, 127] }, - { buttons: 1 }, - { buttons: 3 }, -]; - -/** Payload bytes of one input frame. */ -export function encodeInput(input: HoodInput): Uint8Array { - return encodePayload(INPUT_LAYOUT, input); -} - -/** Decode one input frame (no sanitizing). */ -export function decodeInput(payload: Uint8Array): HoodInput { - return decodePayload(INPUT_LAYOUT, payload) as unknown as HoodInput; -} - -/** What the zone simulates for `input` (`#[range]` clamps, `#[buttons]` masks, wire precision). */ -export function sanitizeInput(input: HoodInput): HoodInput { - return sanitizePayload(INPUT_LAYOUT, INPUT_RULES, input)!; -} diff --git a/examples/game/client/src/generated/ripple/predict.ts b/examples/game/client/src/generated/ripple/predict.ts deleted file mode 100644 index 4c5324a7..00000000 --- a/examples/game/client/src/generated/ripple/predict.ts +++ /dev/null @@ -1,51 +0,0 @@ -// @generated by `distributed game-client` from game surface `ripple` (schema v3, protocol v1). -// Client-predicted step: the Rust `#[predict]` fn compiled to WebAssembly (binary ABI). -// Do not edit: change the Rust definitions and regenerate. -import { createWasmPrediction, type PredictStateLayout, type WasmPrediction, type WasmSource } from '@hops-ops/distributed/game'; - -import { encodeInput, type HoodInput } from './input.ts'; - -/** The predicted step: `#[predict]` fn `hood_step` of Cargo package `game_sim`, predicting kind `player`. */ -export const PREDICT = { - kind: 'player', - export: 'hood_step', - package: 'game_sim', - state: 'MoveState', - abi: 1, -} as const; - -/** Predicted `player` fields (record `MoveState`): wire field name and codec type, in record order. */ -export const PREDICT_STATE: PredictStateLayout = [ - ['x', { type: 'f32' }], - ['y', { type: 'f32' }], - ['vx', { type: 'f32' }], - ['vy', { type: 'f32' }], - ['dash', { type: 'u8' }], - ['cd', { type: 'u8' }], -]; - -/** The step takes no context. */ -export type PredictContext = undefined; - -/** The step takes no context. */ -export const PREDICT_CONTEXT = undefined; - -/** The compiled step (`distributed game-wasm`); bundlers emit it as an asset. */ -export const PREDICT_WASM_URL: URL = new URL('./wasm/ripple.wasm', import.meta.url); - -/** The `ripple` step as `GameClient` prediction (see `createWasmPrediction`). */ -export type RipplePrediction = WasmPrediction; - -/** - * Load the compiled step (default `PREDICT_WASM_URL`) as prediction: `predict` - * leaves the state unchanged until `ready` resolves. - */ -export function createRipplePrediction(source: WasmSource = PREDICT_WASM_URL): RipplePrediction { - return createWasmPrediction({ - source, - exportName: PREDICT.export, - state: PREDICT_STATE, - context: PREDICT_CONTEXT, - encodeInput - }); -} diff --git a/examples/game/client/src/generated/ripple/schema.ts b/examples/game/client/src/generated/ripple/schema.ts deleted file mode 100644 index 19daac91..00000000 --- a/examples/game/client/src/generated/ripple/schema.ts +++ /dev/null @@ -1,152 +0,0 @@ -// @generated by `distributed game-client` from game surface `ripple` (schema v3, protocol v1). -// Replicated kinds: ids, typed fields, wire types, correction defaults. -// Do not edit: change the Rust definitions and regenerate. -import { fieldBool, fieldNumber, fieldString, type CorrectionPolicies, type EntityFields, type WireType } from '@hops-ops/distributed/game'; - -/** Replicated kind ids by name. */ -export const KIND = { - tier: 1, - boss: 2, - player: 3, -} as const; - -export type KindName = keyof typeof KIND; - -/** `tier` (kind 1, priority 1). */ -export interface TierFields { - /** `u8` */ - readonly defeated: number; - /** `u8` */ - readonly total: number; - /** `u8` */ - readonly cleared: number; - /** `bool` */ - readonly liberated: boolean; - /** `u8` */ - readonly weather: number; - /** `u32` */ - readonly clock: number; - /** `u32` */ - readonly events: number; -} - -/** True for a `tier` entity. */ -export function isTier(entity: { readonly kind: number }): boolean { - return entity.kind === 1; -} - -/** Typed fields of a `tier` entity (rendered or replica). */ -export function tierFields(entity: { readonly fields: EntityFields }): TierFields { - const f = entity.fields; - return { - defeated: fieldNumber(f, 'defeated'), - total: fieldNumber(f, 'total'), - cleared: fieldNumber(f, 'cleared'), - liberated: fieldBool(f, 'liberated'), - weather: fieldNumber(f, 'weather'), - clock: fieldNumber(f, 'clock'), - events: fieldNumber(f, 'events'), - }; -} - -/** `boss` (kind 2, priority 1). */ -export interface BossFields { - /** `u16` */ - readonly hp: number; - /** `u16` */ - readonly max: number; - /** `short_str` */ - readonly by: string; -} - -/** True for a `boss` entity. */ -export function isBoss(entity: { readonly kind: number }): boolean { - return entity.kind === 2; -} - -/** Typed fields of a `boss` entity (rendered or replica). */ -export function bossFields(entity: { readonly fields: EntityFields }): BossFields { - const f = entity.fields; - return { - hp: fieldNumber(f, 'hp'), - max: fieldNumber(f, 'max'), - by: fieldString(f, 'by'), - }; -} - -/** `player` (kind 3, priority 2). */ -export interface PlayerFields { - /** `quant [-20, 19.999847] / 18 bits` */ - readonly x: number; - /** `quant [-20, 19.999847] / 18 bits` */ - readonly y: number; - /** `quant [-16, 15.9921875] / 12 bits` */ - readonly vx: number; - /** `quant [-16, 15.9921875] / 12 bits` */ - readonly vy: number; - /** `u8` */ - readonly dash: number; - /** `u8` */ - readonly cd: number; - /** `short_str`, priority 0.2 */ - readonly name: string; -} - -/** True for a `player` entity. */ -export function isPlayer(entity: { readonly kind: number }): boolean { - return entity.kind === 3; -} - -/** Typed fields of a `player` entity (rendered or replica). */ -export function playerFields(entity: { readonly fields: EntityFields }): PlayerFields { - const f = entity.fields; - return { - x: fieldNumber(f, 'x'), - y: fieldNumber(f, 'y'), - vx: fieldNumber(f, 'vx'), - vy: fieldNumber(f, 'vy'), - dash: fieldNumber(f, 'dash'), - cd: fieldNumber(f, 'cd'), - name: fieldString(f, 'name'), - }; -} - -/** Wire type of every replicated field, by kind and wire field name. */ -export const FIELD_TYPES = { - tier: { - defeated: { type: 'u8' }, - total: { type: 'u8' }, - cleared: { type: 'u8' }, - liberated: { type: 'bool' }, - weather: { type: 'u8' }, - clock: { type: 'u32' }, - events: { type: 'u32' }, - }, - boss: { - hp: { type: 'u16' }, - max: { type: 'u16' }, - by: { type: 'short_str' }, - }, - player: { - x: { type: 'quant', min: -20, max: 19.999847412109375, bits: 18 }, - y: { type: 'quant', min: -20, max: 19.999847412109375, bits: 18 }, - vx: { type: 'quant', min: -16, max: 15.9921875, bits: 12 }, - vy: { type: 'quant', min: -16, max: 15.9921875, bits: 12 }, - dash: { type: 'u8' }, - cd: { type: 'u8' }, - name: { type: 'short_str' }, - }, -} as const satisfies Record>; - -/** Client correction defaults (`#[correct(..)]`), by kind and wire field name. */ -export const CORRECTIONS: CorrectionPolicies = { - player: { - x: { mode: 'ease', durationMs: 150 }, - y: { mode: 'ease', durationMs: 150 }, - vx: { mode: 'snap' }, - vy: { mode: 'snap' }, - dash: { mode: 'snap' }, - cd: { mode: 'snap' }, - name: { mode: 'replace' }, - }, -}; diff --git a/examples/game/client/src/generated/ripple/surface.json b/examples/game/client/src/generated/ripple/surface.json deleted file mode 100644 index f795c485..00000000 --- a/examples/game/client/src/generated/ripple/surface.json +++ /dev/null @@ -1,684 +0,0 @@ -{ - "constants": [ - { - "name": "arena_floor", - "type": { - "name": "ArenaFloor", - "type": "record" - }, - "value": { - "dash_cooldown": 45, - "half": 20.0, - "pillars": [ - { - "r": 2.5, - "x": 0.0, - "y": 0.0 - }, - { - "r": 1.5, - "x": -9.0, - "y": -9.0 - }, - { - "r": 1.5, - "x": 9.0, - "y": -9.0 - }, - { - "r": 1.5, - "x": -9.0, - "y": 9.0 - }, - { - "r": 1.5, - "x": 9.0, - "y": 9.0 - }, - { - "r": 1.0, - "x": 0.0, - "y": -14.0 - }, - { - "r": 1.0, - "x": 0.0, - "y": 14.0 - }, - { - "r": 1.0, - "x": -14.0, - "y": 0.0 - }, - { - "r": 1.0, - "x": 14.0, - "y": 0.0 - } - ], - "player_radius": 0.5 - } - }, - { - "name": "ripple_world", - "type": { - "name": "RippleWorld", - "type": "record" - }, - "value": { - "boroughs": [ - { - "hoods": [ - "realm.city.borough-1.hood-1", - "realm.city.borough-1.hood-2", - "realm.city.borough-1.hood-3" - ], - "id": "realm.city.borough-1" - }, - { - "hoods": [ - "realm.city.borough-2.hood-1", - "realm.city.borough-2.hood-2", - "realm.city.borough-2.hood-3" - ], - "id": "realm.city.borough-2" - } - ], - "city": "realm.city", - "realm": "realm", - "weather": [ - "storm", - "rain", - "clearing", - "clear" - ] - } - } - ], - "events": [ - { - "id": 1, - "name": "hello", - "payload": { - "fields": [ - { - "name": "zone", - "type": { - "type": "short_str" - } - }, - { - "name": "tier", - "type": { - "type": "short_str" - } - }, - { - "name": "parent", - "type": { - "item": { - "type": "short_str" - }, - "type": "optional" - } - }, - { - "name": "children", - "type": { - "item": { - "type": "short_str" - }, - "type": "list" - } - }, - { - "name": "host", - "type": { - "type": "short_str" - } - }, - { - "name": "tick_rate", - "type": { - "type": "u32" - } - }, - { - "name": "entity", - "type": { - "item": { - "type": "u32" - }, - "type": "optional" - } - }, - { - "name": "weather", - "type": { - "item": { - "name": "Weather", - "type": "record" - }, - "type": "optional" - } - }, - { - "name": "banner", - "type": { - "type": "short_str" - } - } - ], - "kind": "fields" - } - }, - { - "id": 2, - "name": "ripple", - "payload": { - "fields": [ - { - "name": "at", - "type": { - "type": "short_str" - } - }, - { - "name": "kind", - "type": { - "type": "short_str" - } - }, - { - "name": "from", - "type": { - "type": "short_str" - } - }, - { - "name": "origin", - "type": { - "type": "short_str" - } - }, - { - "name": "origin_tick", - "type": { - "type": "u64" - } - }, - { - "name": "tick", - "type": { - "type": "u64" - } - } - ], - "kind": "fields" - } - }, - { - "id": 3, - "name": "weather", - "payload": { - "kind": "value", - "type": { - "name": "Weather", - "type": "record" - } - } - }, - { - "id": 4, - "name": "banner", - "payload": { - "fields": [ - { - "name": "text", - "type": { - "type": "short_str" - } - } - ], - "kind": "fields" - } - }, - { - "id": 5, - "name": "boss", - "payload": { - "fields": [ - { - "name": "kind", - "type": { - "type": "short_str" - } - }, - { - "name": "by", - "type": { - "item": { - "type": "short_str" - }, - "type": "optional" - } - } - ], - "kind": "fields" - } - } - ], - "input": { - "fields": [ - { - "name": "mx", - "range": [ - -127.0, - 127.0 - ], - "type": { - "type": "i8" - } - }, - { - "name": "my", - "range": [ - -127.0, - 127.0 - ], - "type": { - "type": "i8" - } - }, - { - "buttons": [ - "dash" - ], - "name": "buttons", - "type": { - "type": "u8" - } - }, - { - "buttons": [ - "attack", - "smite" - ], - "name": "action", - "type": { - "type": "u8" - } - } - ], - "name": "HoodInput" - }, - "kinds": [ - { - "fields": [ - { - "correction": null, - "name": "defeated", - "priority": 1.0, - "type": { - "type": "u8" - } - }, - { - "correction": null, - "name": "total", - "priority": 1.0, - "type": { - "type": "u8" - } - }, - { - "correction": null, - "name": "cleared", - "priority": 1.0, - "type": { - "type": "u8" - } - }, - { - "correction": null, - "name": "liberated", - "priority": 1.0, - "type": { - "type": "bool" - } - }, - { - "correction": null, - "name": "weather", - "priority": 1.0, - "type": { - "type": "u8" - } - }, - { - "correction": null, - "name": "clock", - "priority": 1.0, - "type": { - "type": "u32" - } - }, - { - "correction": null, - "name": "events", - "priority": 1.0, - "type": { - "type": "u32" - } - } - ], - "id": 1, - "name": "tier", - "priority": 1.0 - }, - { - "fields": [ - { - "correction": null, - "name": "hp", - "priority": 1.0, - "type": { - "type": "u16" - } - }, - { - "correction": null, - "name": "max", - "priority": 1.0, - "type": { - "type": "u16" - } - }, - { - "correction": null, - "name": "by", - "priority": 1.0, - "type": { - "type": "short_str" - } - } - ], - "id": 2, - "name": "boss", - "priority": 1.0 - }, - { - "fields": [ - { - "correction": { - "durationMs": 150, - "mode": "ease" - }, - "name": "x", - "priority": 1.0, - "type": { - "bits": 18, - "max": 19.999847, - "min": -20.0, - "type": "quant" - } - }, - { - "correction": { - "durationMs": 150, - "mode": "ease" - }, - "name": "y", - "priority": 1.0, - "type": { - "bits": 18, - "max": 19.999847, - "min": -20.0, - "type": "quant" - } - }, - { - "correction": { - "mode": "snap" - }, - "name": "vx", - "priority": 1.0, - "type": { - "bits": 12, - "max": 15.9921875, - "min": -16.0, - "type": "quant" - } - }, - { - "correction": { - "mode": "snap" - }, - "name": "vy", - "priority": 1.0, - "type": { - "bits": 12, - "max": 15.9921875, - "min": -16.0, - "type": "quant" - } - }, - { - "correction": { - "mode": "snap" - }, - "name": "dash", - "priority": 1.0, - "type": { - "type": "u8" - } - }, - { - "correction": { - "mode": "snap" - }, - "name": "cd", - "priority": 1.0, - "type": { - "type": "u8" - } - }, - { - "correction": { - "mode": "replace" - }, - "name": "name", - "priority": 0.2, - "type": { - "type": "short_str" - } - } - ], - "id": 3, - "name": "player", - "priority": 2.0 - } - ], - "name": "ripple", - "predict": { - "abi": 1, - "export": "hood_step", - "kind": "player", - "package": "game_sim", - "state": "MoveState" - }, - "protocolVersion": 1, - "records": [ - { - "fields": [ - { - "name": "half", - "type": { - "type": "f32" - } - }, - { - "name": "player_radius", - "type": { - "type": "f32" - } - }, - { - "name": "dash_cooldown", - "type": { - "type": "u8" - } - }, - { - "name": "pillars", - "type": { - "item": { - "name": "Pillar", - "type": "record" - }, - "type": "list" - } - } - ], - "name": "ArenaFloor" - }, - { - "fields": [ - { - "name": "id", - "type": { - "type": "short_str" - } - }, - { - "name": "hoods", - "type": { - "item": { - "type": "short_str" - }, - "type": "list" - } - } - ], - "name": "BoroughLayout" - }, - { - "fields": [ - { - "name": "x", - "type": { - "type": "f32" - } - }, - { - "name": "y", - "type": { - "type": "f32" - } - }, - { - "name": "vx", - "type": { - "type": "f32" - } - }, - { - "name": "vy", - "type": { - "type": "f32" - } - }, - { - "name": "dash", - "type": { - "type": "u8" - } - }, - { - "name": "cd", - "type": { - "type": "u8" - } - } - ], - "name": "MoveState" - }, - { - "fields": [ - { - "name": "x", - "type": { - "type": "f32" - } - }, - { - "name": "y", - "type": { - "type": "f32" - } - }, - { - "name": "r", - "type": { - "type": "f32" - } - } - ], - "name": "Pillar" - }, - { - "fields": [ - { - "name": "realm", - "type": { - "type": "short_str" - } - }, - { - "name": "city", - "type": { - "type": "short_str" - } - }, - { - "name": "boroughs", - "type": { - "item": { - "name": "BoroughLayout", - "type": "record" - }, - "type": "list" - } - }, - { - "name": "weather", - "type": { - "item": { - "type": "short_str" - }, - "type": "list" - } - } - ], - "name": "RippleWorld" - }, - { - "fields": [ - { - "name": "weather", - "type": { - "type": "short_str" - } - }, - { - "name": "remaining", - "type": { - "type": "u32" - } - } - ], - "name": "Weather" - } - ], - "schemaVersion": 3 -} diff --git a/examples/game/client/src/ripple/client.ts b/examples/game/client/src/ripple/client.ts deleted file mode 100644 index 41015604..00000000 --- a/examples/game/client/src/ripple/client.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Boss Ripple networking without rendering (Node-testable): - * - * - the player's authoritative zone is one neighborhood (predicted by - * `hood_step`, the Rust Arena movement compiled to WebAssembly; the boss - * stands on the central pillar); - * - every other zone of the hierarchy is observed (realm, city, boroughs - * and the other hoods), so the page shows each tier's live state and - * receives what upper tiers broadcast: ripple events, the city's - * weather, the realm's banner. - */ -import type { GameClient, GameSocketFactory } from '@hops-ops/distributed/game'; - -import * as gen from '../generated/ripple/index.ts'; -import { zoneUrl, type DevApi } from '../shared/dev-api.ts'; - -/** The zone hierarchy and weather names, generated from the Rust `ripple_world()`. */ -const WORLD = gen.RIPPLE_WORLD; -export const REALM = WORLD.realm; -export const CITY = WORLD.city; -export const BOROUGHS: readonly string[] = WORLD.boroughs.map((b) => b.id); -export const DEFAULT_ZONE = WORLD.boroughs[0]!.hoods[0]!; -export const ACTION_ATTACK = gen.BITS.action.attack; -export const ACTION_SMITE = gen.BITS.action.smite; -export const WEATHER = WORLD.weather; - -export type HoodInput = gen.HoodInput; -export type TierName = 'realm' | 'city' | 'borough' | 'hood'; -/** A zone's hello (generated from the Rust `RippleEvent::Hello`). */ -export type RippleHello = gen.HelloEvent; -/** A tier-up event as broadcast by the receiving zone. */ -export type RippleEvent = gen.RippleEvent; - -/** Live state of one tier, from its replicated `tier` (and `boss`) entity. */ -export type TierView = Readonly<{ - zone: string; - hello: RippleHello | undefined; - connected: boolean; - defeated: number; - total: number; - cleared: number; - liberated: boolean; - weather: string; - clock: number; - events: number; - boss?: Readonly<{ hp: number; max: number; by: string }>; -}>; - -export function hoods(borough: string): string[] { - return [...(WORLD.boroughs.find((b) => b.id === borough)?.hoods ?? [])]; -} - -/** Every zone, root first (tree order). */ -export function allZones(): string[] { - const out = [REALM, CITY]; - for (const b of BOROUGHS) out.push(b, ...hoods(b)); - return out; -} - -export function tierOf(zone: string): TierName | undefined { - if (zone === REALM) return 'realm'; - if (zone === CITY) return 'city'; - if (BOROUGHS.includes(zone)) return 'borough'; - return BOROUGHS.some((b) => hoods(b).includes(zone)) ? 'hood' : undefined; -} - -export type RippleClientOptions = Readonly<{ - api: DevApi; - base?: string; - socket?: GameSocketFactory; - /** Observe every other zone of the hierarchy (default true). */ - observeAll?: boolean; -}>; - -export type RippleClient = Readonly<{ - client: GameClient; - /** Resolves on the first hello of the player's hood, once the predicted step is loaded. */ - ready: Promise; - tier(zone: string): TierView; - /** Ripple events received (newest last, bounded). */ - ripples(): readonly RippleEvent[]; - weather(): string | undefined; - banner(): string | undefined; - onRipple(listener: (event: RippleEvent) => void): () => void; - onBanner(listener: (text: string) => void): () => void; - onWeather(listener: (weather: string) => void): () => void; - onBoss(listener: (zone: string, event: { kind: string; by?: string }) => void): () => void; - /** Join `hood` as the player's zone (switches authority). */ - enter(hood: string): Promise; - /** One input with the smite bit (demo shortcut). */ - smite(): void; - close(): void; -}>; - -export function createRippleClient(options: RippleClientOptions): RippleClient { - const hellos = new Map(); - const ripples: RippleEvent[] = []; - let weather: string | undefined; - let banner: string | undefined; - const listeners = { - ripple: new Set<(e: RippleEvent) => void>(), - banner: new Set<(t: string) => void>(), - weather: new Set<(w: string) => void>(), - boss: new Set<(z: string, e: { kind: string; by?: string }) => void>() - }; - const typed = gen.createRippleClient({ - url: (zone) => zoneUrl(zone, options.base), - ticket: options.api.ticketSource(), - socket: options.socket, - pingIntervalMs: 1000, - inputDtMs: 1000 / 30 - // Prediction: the generated default, `hood_step` compiled to WASM. - }); - const { client } = typed; - let resolveReady: (h: RippleHello) => void = () => undefined; - const ready = Promise.all([ - new Promise((resolve) => (resolveReady = resolve)), - typed.prediction?.ready - ]).then(([h]) => h); - - typed.on('hello', (hello, zone) => { - hellos.set(zone, hello); - if (hello.weather !== undefined) weather = hello.weather.weather; - if (hello.tier === 'realm' && hello.banner !== '') banner = hello.banner; - if (hello.entity !== undefined && zone === client.authority?.zoneId) { - client.setControlledEntity(hello.entity); - resolveReady(hello); - } - }); - typed.on('ripple', (event) => { - ripples.push(event); - if (ripples.length > 200) ripples.shift(); - for (const l of listeners.ripple) l(event); - }); - typed.on('weather', (value) => { - weather = value.weather; - for (const l of listeners.weather) l(value.weather); - }); - typed.on('banner', (value) => { - banner = value.text; - for (const l of listeners.banner) l(value.text); - }); - typed.on('boss', (value, zone) => { - for (const l of listeners.boss) l(zone, value); - }); - - const observeOthers = () => { - if (options.observeAll === false) return; - const authority = client.authority?.zoneId; - for (const zone of allZones()) { - if (zone === authority || client.zone(zone)?.role === 'observer') continue; - client.observe(zone).catch(() => undefined); - } - }; - client.on('authority', observeOthers); - - const on = (set: Set, l: T) => { - set.add(l); - return () => set.delete(l); - }; - return { - client, - ready, - tier(zone) { - const view = client.zone(zone); - const tier = gen.tierFields({ fields: view?.replica.get(1)?.fields ?? {} }); - const boss = view?.replica.get(2); - return { - zone, - hello: hellos.get(zone), - connected: view?.connection.state === 'open', - defeated: tier.defeated, - total: tier.total, - cleared: tier.cleared, - liberated: tier.liberated, - weather: WEATHER[tier.weather] ?? 'storm', - clock: tier.clock, - events: tier.events, - boss: boss === undefined ? undefined : gen.bossFields(boss) - }; - }, - ripples: () => ripples, - weather: () => weather, - banner: () => banner, - onRipple: (l) => on(listeners.ripple, l), - onBanner: (l) => on(listeners.banner, l), - onWeather: (l) => on(listeners.weather, l), - onBoss: (l) => on(listeners.boss, l), - enter: (hood) => client.connect(hood), - smite() { - client.sendInput({ mx: 0, my: 0, buttons: 0, action: ACTION_SMITE }); - }, - close: () => client.close() - }; -} diff --git a/examples/game/client/src/ripple/main.ts b/examples/game/client/src/ripple/main.ts deleted file mode 100644 index 9ffa4632..00000000 --- a/examples/game/client/src/ripple/main.ts +++ /dev/null @@ -1,328 +0,0 @@ -/** - * Boss Ripple page: a playable neighborhood (Babylon) next to the live - * hierarchy (DOM + SVG), both over `createRippleClient`. - * - * - fight the boss in your hood (E near it, or "Smite"); its defeat travels - * up as tier-up events — watch the pulse climb hood → borough → city → - * realm in the tree (each hop is a cross-host admin call with two hosts); - * - the borough unlocks when all its bosses fell, the city's weather - * follows the remaining bosses (it is broadcast to everyone observing - * the city — including you, so the sky over your hood changes), and the - * realm shows a banner when the city is liberated; - * - each tier shows its own tick rate and clock (realm 2 Hz … hood 30 Hz). - */ -import { Color3, Color4 } from '@babylonjs/core/Maths/math.color'; -import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh'; -import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder'; -import { Scene } from '@babylonjs/core/scene'; -import { InputPacer, type RenderedEntity } from '@hops-ops/distributed/game'; - -import { ARENA_FLOOR } from '../generated/ripple/constants.ts'; -import { BITS, isPlayer, playerFields } from '../generated/ripple/index.ts'; -import { DevApi } from '../shared/dev-api.ts'; -import { Hud } from '../shared/hud.ts'; -import { cameraRelative, InputSampler, toAxis } from '../shared/input.ts'; -import { createStage, material, toWorld } from '../shared/scene.ts'; -import { - ACTION_ATTACK, - ACTION_SMITE, - allZones, - createRippleClient, - DEFAULT_ZONE, - tierOf, - type HoodInput, - type RippleEvent -} from './client.ts'; -import './ripple.css'; - -/** The Arena floor, generated from the simulation's constants (`game_sim::arena::arena_floor`). */ -const { half: HALF, playerRadius: PLAYER_RADIUS } = ARENA_FLOOR; - -const params = new URLSearchParams(location.search); -let hood = params.get('hood') ?? DEFAULT_ZONE; -if (tierOf(hood) !== 'hood') hood = DEFAULT_ZONE; -const name = params.get('name') ?? undefined; - -const api = new DevApi({ name: () => name }); -const ripple = createRippleClient({ api }); -const { client } = ripple; - -// ---- hood scene ---- -const canvas = document.getElementById('stage') as HTMLCanvasElement; -const stage = createStage(canvas, { half: HALF }); -const { scene } = stage; -scene.fogMode = Scene.FOGMODE_EXP2; -const sky = { - storm: { clear: '#141821', fog: 0.035, light: 0.35 }, - rain: { clear: '#2a3442', fog: 0.022, light: 0.55 }, - clearing: { clear: '#56708c', fog: 0.012, light: 0.8 }, - clear: { clear: '#8fc3f0', fog: 0.004, light: 1.1 } -} as const; -let weather: keyof typeof sky = 'storm'; -const lights = scene.lights.map((l) => ({ light: l, base: l.intensity })); -function applySky(dtMs: number): void { - const target = sky[weather]; - const want = Color4.FromHexString(`${target.clear}ff`); - const k = 1 - Math.exp(-dtMs / 400); - scene.clearColor = Color4.Lerp(scene.clearColor, want, k); - scene.fogColor = new Color3(scene.clearColor.r, scene.clearColor.g, scene.clearColor.b); - scene.fogDensity += (target.fog - scene.fogDensity) * k; - const flash = weather === 'storm' && Math.random() < 0.004 ? 2.5 : 1; - for (const { light, base } of lights) light.intensity += (base * target.light * flash - light.intensity) * Math.min(1, k * (flash > 1 ? 20 : 1)); -} - -const mats = { - me: material(scene, 'me', '#6cc4ff'), - other: material(scene, 'other', '#f2a65a'), - pillar: material(scene, 'pillar', '#5d6675'), - boss: material(scene, 'boss', '#d0453b'), - down: material(scene, 'down', '#4a4a4a') -}; -mats.boss.emissiveColor = Color3.FromHexString('#3a0b08'); -for (const [i, { x, y, r }] of ARENA_FLOOR.pillars.entries()) { - if (i === 0) continue; // the boss stands on the central pillar - const ruin = MeshBuilder.CreateCylinder(`ruin-${i}`, { diameter: r * 2, height: 1.2 + (i % 3) * 0.8, tessellation: 7 }, scene); - ruin.position = toWorld(x, y, ruin.getBoundingInfo().boundingBox.extendSize.y); - ruin.material = mats.pillar; -} -const boss = MeshBuilder.CreateSphere('boss', { diameter: ARENA_FLOOR.pillars[0]!.r * 2, segments: 12 }, scene); -boss.scaling.y = 1.3; -boss.material = mats.boss; -boss.position = toWorld(0, 0, 2.2); - -const visuals = new Map(); -function capsule(entity: RenderedEntity, mine: boolean): AbstractMesh { - let mesh = visuals.get(entity.key); - if (mesh === undefined) { - mesh = MeshBuilder.CreateCapsule(entity.key, { radius: PLAYER_RADIUS, height: 1.8 }, scene); - mesh.material = mine ? mats.me : mats.other; - visuals.set(entity.key, mesh); - } - return mesh; -} - -// ---- HUD, banner ---- -const hud = new Hud({ - title: 'Boss Ripple', - hint: 'WASD to move, Space to dash, E near the boss to attack. Click a neighborhood in the tree to fight there.', - client, - devApi: api -}); -let smitePending = false; -const smite = document.createElement('button'); -smite.className = 'ripple-button'; -smite.textContent = 'Smite the boss (demo)'; -smite.addEventListener('click', () => (smitePending = true)); -hud.root.append(smite); - -const banner = document.createElement('div'); -banner.className = 'ripple-banner'; -document.body.append(banner); -function showBanner(text: string): void { - banner.textContent = text; - banner.classList.remove('show'); - void banner.offsetWidth; - banner.classList.add('show'); -} -ripple.onBanner(showBanner); -ripple.onWeather((w) => { - if (w in sky) weather = w as keyof typeof sky; -}); - -// ---- hierarchy tree ---- -const tree = document.createElement('aside'); -tree.className = 'ripple-tree'; -const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); -svg.classList.add('ripple-edges'); -tree.append(svg); -const nodes = new Map(); -for (const zone of allZones()) { - const tier = tierOf(zone)!; - const el = document.createElement('div'); - el.className = `ripple-node tier-${tier}`; - const title = document.createElement('div'); - title.className = 'title'; - title.textContent = zone.split('.').at(-1)!; - const meta = document.createElement('span'); - meta.className = 'meta'; - title.append(meta); - const state = document.createElement('div'); - state.className = 'state'; - el.append(title, state); - let bar: HTMLElement | undefined; - if (tier === 'hood') { - const track = document.createElement('div'); - track.className = 'hp'; - bar = document.createElement('div'); - track.append(bar); - el.append(track); - el.title = 'click to fight here'; - el.addEventListener('click', () => { - if (zone === hood) return; - hood = zone; - history.replaceState(null, '', `?hood=${encodeURIComponent(zone)}${name === undefined ? '' : `&name=${encodeURIComponent(name)}`}`); - ripple.enter(zone).catch(() => undefined); - }); - } - tree.append(el); - nodes.set(zone, { el, state, bar }); - meta.dataset.role = 'meta'; -} -const log = document.createElement('ol'); -log.className = 'ripple-log'; -tree.append(log); -document.body.append(tree); - -function center(zone: string): { x: number; y: number } | undefined { - const node = nodes.get(zone)?.el; - if (node === undefined) return undefined; - const box = tree.getBoundingClientRect(); - const r = node.getBoundingClientRect(); - return { x: r.left - box.left + 10, y: r.top - box.top + r.height / 2 + tree.scrollTop }; -} -function drawEdges(): void { - svg.setAttribute('width', String(tree.scrollWidth)); - svg.setAttribute('height', String(tree.scrollHeight)); - const lines: string[] = []; - for (const zone of allZones()) { - const parent = zone.includes('.') ? zone.slice(0, zone.lastIndexOf('.')) : undefined; - if (parent === undefined) continue; - const a = center(parent); - const b = center(zone); - if (a === undefined || b === undefined) continue; - lines.push(``); - } - svg.innerHTML = lines.join(''); -} -window.addEventListener('resize', drawEdges); -requestAnimationFrame(drawEdges); - -function pulse(event: RippleEvent): void { - const from = center(event.from); - const to = center(event.at); - nodes.get(event.at)?.el.classList.remove('pulse'); - void nodes.get(event.at)?.el.offsetWidth; - nodes.get(event.at)?.el.classList.add('pulse'); - const item = document.createElement('li'); - item.textContent = `${event.from.split('.').at(-1)} → ${event.at.split('.').at(-1)}: ${event.kind}`; - log.prepend(item); - while (log.children.length > 8) log.lastChild?.remove(); - if (from === undefined || to === undefined) return; - const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - dot.setAttribute('r', '5'); - dot.classList.add(event.kind.includes('respawn') || event.kind.includes('lost') || event.kind.includes('fallen') ? 'bad' : 'good'); - svg.append(dot); - const start = performance.now(); - const step = (now: number) => { - const t = Math.min(1, (now - start) / 600); - // Up the elbow: first along x back to the parent column, then up. - const [x, y] = t < 0.5 ? [from.x + (to.x - from.x) * t * 2, from.y] : [to.x, from.y + (to.y - from.y) * (t - 0.5) * 2]; - dot.setAttribute('cx', String(x)); - dot.setAttribute('cy', String(y)); - if (t < 1) requestAnimationFrame(step); - else dot.remove(); - }; - requestAnimationFrame(step); -} -ripple.onRipple(pulse); -ripple.onBoss((zone, event) => { - if (zone === hood && event.kind === 'defeated') showBanner(`Boss down! (${event.by ?? 'someone'})`); -}); - -function refreshTree(): void { - for (const zone of allZones()) { - const node = nodes.get(zone)!; - const view = ripple.tier(zone); - const hello = view.hello; - const meta = node.el.querySelector('[data-role="meta"]')!; - meta.textContent = hello === undefined ? ' · …' : ` · host ${hello.host} · ${hello.tickRate} Hz · t${view.clock}`; - node.el.classList.toggle('offline', !view.connected); - node.el.classList.toggle('here', zone === client.authority?.zoneId); - let text = ''; - switch (tierOf(zone)) { - case 'realm': - text = `bosses ${view.defeated}/${view.total} · boroughs ${view.cleared}/2 · ${view.liberated ? 'city free' : 'city occupied'}`; - break; - case 'city': - text = `${view.weather} · boroughs cleared ${view.cleared}/2${view.liberated ? ' · LIBERATED' : ''}`; - break; - case 'borough': - text = `bosses down ${view.defeated}/3 · ${view.cleared > 0 ? 'unlocked' : 'locked'}`; - node.el.classList.toggle('cleared', view.cleared > 0); - break; - default: { - const b = view.boss; - text = b === undefined ? '…' : b.hp === 0 ? `defeated by ${b.by || '?'}` : `boss ${b.hp}/${b.max}`; - if (node.bar !== undefined && b !== undefined) node.bar.style.width = `${(100 * b.hp) / Math.max(1, b.max)}%`; - node.el.classList.toggle('cleared', b?.hp === 0); - } - } - if (node.state.textContent !== text) node.state.textContent = text; - } -} - -// ---- input ---- -const sampler = new InputSampler(); -const frames = new InputPacer({ tickRate: () => client.authority?.connection.welcome?.tickRate }); -function sendFrame(): void { - const s = sampler.sample(); - const move = cameraRelative(s, stage.cameraForward()); - const input: HoodInput = { - mx: toAxis(move.x), - my: toAxis(move.y), - buttons: s.action ? BITS.buttons.dash : 0, - action: (s.alt ? ACTION_ATTACK : 0) | (smitePending ? ACTION_SMITE : 0) - }; - if (client.sendInput(input) !== undefined) smitePending = false; -} - -let lastTree = 0; -stage.onFrame((now, dtMs) => { - frames.update(now, sendFrame); - applySky(dtMs); - const authority = client.authority; - const view = authority === undefined ? undefined : ripple.tier(authority.zoneId); - const hp = view?.boss?.hp ?? 1; - boss.material = hp === 0 ? mats.down : mats.boss; - const s = hp === 0 ? 0.35 : 0.55 + 0.45 * (hp / Math.max(1, view?.boss?.max ?? 1)); - boss.scaling.set(s, 1.3 * s, s); - boss.position.y = 2.2 * s; - stage.labels.set('boss', boss, hp === 0 ? `defeated by ${view?.boss?.by ?? ''}` : `Boss ${hp}/${view?.boss?.max ?? ''}`, 2.2); - - const rendered = client.render().filter((e) => e.authoritative && isPlayer(e)); - const seen = new Set(); - for (const entity of rendered) { - const mine = entity.predicted; - const mesh = capsule(entity, mine); - const { x, y, name: label } = playerFields(entity); - mesh.position.copyFrom(toWorld(x, y, 0.9)); - mesh.setEnabled(!entity.stale); - stage.labels.set(entity.key, mesh, mine ? `${label} (you)` : label, 1.4, mine ? 'me' : ''); - if (mine) stage.follow(x, y, dtMs); - seen.add(entity.key); - } - for (const [key, mesh] of visuals) { - if (seen.has(key)) continue; - mesh.dispose(); - stage.labels.delete(key); - visuals.delete(key); - } - if (now - lastTree > 150) { - lastTree = now; - refreshTree(); - } - hud.frame({ entities: rendered.length }); - hud.row('Hood', authority?.zoneId ?? '—'); - hud.row('Weather', ripple.weather() ?? '—'); - hud.row('Realm banner', ripple.banner() ?? '—'); -}); - -declare global { - interface Window { - /** Debug handle (console). */ - ripple?: unknown; - } -} -window.ripple = { ripple, client, api, scene }; - -client.connect(hood).catch((error: unknown) => hud.row('Status', `failed: ${String(error)}`)); diff --git a/examples/game/client/src/ripple/ripple.css b/examples/game/client/src/ripple/ripple.css deleted file mode 100644 index 80812c18..00000000 --- a/examples/game/client/src/ripple/ripple.css +++ /dev/null @@ -1,148 +0,0 @@ -.ripple-tree { - position: fixed; - top: 12px; - right: 12px; - width: 330px; - max-height: calc(100% - 24px); - overflow: auto; - padding: 10px 12px 10px 10px; - border-radius: 8px; - background: var(--panel); - box-sizing: border-box; -} -.ripple-edges { - position: absolute; - left: 0; - top: 0; - pointer-events: none; - overflow: visible; -} -.ripple-edges path { - fill: none; - stroke: #3a4556; - stroke-width: 1.5; -} -.ripple-edges circle.good { - fill: #ffd25e; - filter: drop-shadow(0 0 4px #ffd25e); -} -.ripple-edges circle.bad { - fill: #ff6b6b; - filter: drop-shadow(0 0 4px #ff6b6b); -} -.ripple-node { - position: relative; - margin: 0 0 6px; - padding: 5px 8px; - border-radius: 6px; - border: 1px solid #2a3240; - background: #141922; -} -.ripple-node .title { - font-weight: 600; -} -.ripple-node .meta { - font-weight: 400; - color: var(--muted); - font-size: 11px; -} -.ripple-node .state { - color: var(--text); - font-size: 12px; -} -.ripple-node.tier-city { - margin-left: 16px; -} -.ripple-node.tier-borough { - margin-left: 32px; -} -.ripple-node.tier-hood { - margin-left: 48px; - cursor: pointer; -} -.ripple-node.here { - border-color: var(--accent); -} -.ripple-node.cleared { - border-color: #5fd39a; -} -.ripple-node.offline { - opacity: 0.5; -} -.ripple-node.pulse { - animation: ripple-pulse 0.9s ease-out; -} -@keyframes ripple-pulse { - 0% { - box-shadow: 0 0 0 0 rgba(255, 210, 94, 0.8); - } - 100% { - box-shadow: 0 0 0 12px rgba(255, 210, 94, 0); - } -} -.ripple-node .hp { - height: 4px; - margin-top: 4px; - border-radius: 2px; - background: #2a3240; - overflow: hidden; -} -.ripple-node .hp div { - height: 100%; - background: #d0453b; - transition: width 0.2s; -} -.ripple-log { - margin: 8px 0 0; - padding-left: 18px; - color: var(--muted); - font-size: 11px; -} -.ripple-button { - display: block; - width: 100%; - margin: 8px 0 0; - padding: 6px; - border: 1px solid #d0453b; - border-radius: 6px; - background: #2a1414; - color: #ffd7d2; - font: inherit; - cursor: pointer; -} -.ripple-banner { - position: fixed; - top: 18%; - left: 50%; - transform: translateX(-50%); - padding: 12px 22px; - border-radius: 10px; - background: rgba(16, 20, 27, 0.9); - border: 1px solid #ffd25e; - color: #ffe9a8; - font-size: 20px; - opacity: 0; - pointer-events: none; - max-width: calc(100% - 32px); - text-align: center; -} -.ripple-banner.show { - animation: ripple-banner 4s ease-out; -} -@keyframes ripple-banner { - 0%, - 80% { - opacity: 1; - } - 100% { - opacity: 0; - } -} -@media (max-width: 640px) { - .ripple-tree { - top: auto; - bottom: 12px; - width: calc(100% - 24px); - max-height: 40%; - } -} diff --git a/examples/game/client/tests/helpers/cluster.mjs b/examples/game/client/tests/helpers/cluster.mjs deleted file mode 100644 index d12d0f6a..00000000 --- a/examples/game/client/tests/helpers/cluster.mjs +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Start a multi-host demo (`game-demos --host-id … --peer …`) for a - * Node test: one process per host on free ephemeral ports, a shared ticket - * secret, dev routes on, and one private `celld dev` (zone leases and - * hibernation snapshots in `examples/game/cells`) whose outbox Queue relays - * to the first host; the hosts share a throwaway data dir (projection bus, - * zone status read models). Uses GAME_DEMOS_BIN when set, else `cargo run`. - */ -import { spawn } from 'node:child_process'; -import { mkdtempSync } from 'node:fs'; -import { Socket } from 'node:net'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { freePort, startCelld } from './celld.mjs'; - -const SERVER_DIR = fileURLToPath(new URL('../../../server/', import.meta.url)); - -/** - * @param {string} demo - * @param {{ hosts?: string[], hibernateAfterMs?: number, env?: Record }} options - */ -export async function startCluster(demo, { hosts = ['A', 'B'], hibernateAfterMs = 4000, env = {} } = {}) { - const ports = []; - for (const _ of hosts) ports.push(await freePort()); - const celld = await startCelld({ relayPort: ports[0] }); - const dataDir = mkdtempSync(join(tmpdir(), `${demo}-js-`)); - const secret = `test-secret-${process.pid}-${Date.now()}`; - const bin = process.env.GAME_DEMOS_BIN; - const children = []; - const urls = {}; - try { - await Promise.all( - hosts.map(async (id, i) => { - const args = [demo, '--dev', '--port', String(ports[i]), '--host-id', id, '--secret', secret]; - args.push('--hibernate-after-ms', String(hibernateAfterMs)); - hosts.forEach((peer, j) => { - if (j !== i) args.push('--peer', `${peer}=127.0.0.1:${ports[j]}`); - }); - const options = { - env: { ...process.env, ...celld.env, GAME_DEMOS_DATA_DIR: dataDir, ...env }, - stdio: ['ignore', 'pipe', 'inherit'] - }; - const child = bin - ? spawn(bin, args, options) - : spawn('cargo', ['run', '-q', '--', ...args], { ...options, cwd: SERVER_DIR }); - children.push(child); - urls[id] = await new Promise((resolve, reject) => { - let buffered = ''; - child.stdout.on('data', (chunk) => { - buffered += chunk; - const match = /LISTENING (http:\/\/\S+)/.exec(buffered); - if (match) resolve(match[1]); - }); - child.on('exit', (code) => reject(new Error(`game-demos ${demo} host ${id} exited with ${code}`))); - }); - }) - ); - } catch (error) { - for (const child of children) child.kill('SIGKILL'); - await celld.stop(); - throw error; - } - return { - /** HTTP origin per host id. */ - urls, - /** First host (what the Vite proxy points at in `run.sh`). */ - httpUrl: urls[hosts[0]], - async stop() { - await Promise.all( - children.map(async (child) => { - if (child.exitCode !== null) return; - const exited = new Promise((resolve) => child.on('exit', resolve)); - child.kill('SIGTERM'); - const timer = setTimeout(() => child.kill('SIGKILL'), 5000); - await exited; - clearTimeout(timer); - }) - ); - await celld.stop(); - // Node's WebSocket (undici) keeps a socket whose closing handshake - // the server started (Redirect) paused until a ~60 s timeout, which - // would hold the test process open: drop what still points at us. - for (const handle of process._getActiveHandles()) { - if (handle instanceof Socket && ports.includes(handle.remotePort)) handle.destroy(); - } - } - }; -} diff --git a/examples/game/client/tests/ripple-headless.test.mjs b/examples/game/client/tests/ripple-headless.test.mjs deleted file mode 100644 index ef43db75..00000000 --- a/examples/game/client/tests/ripple-headless.test.mjs +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Headless Boss Ripple client against two real `game-demos ripple` - * processes (placement alternates by depth, so each hop crosses hosts): - * the client plays a neighborhood, observes every tier, smites the bosses - * of borough-1 and sees the cascade arrive at the borough, the city (rain) - * and the realm, each tier running its own tick rate. - */ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { allZones, BOROUGHS, CITY, createRippleClient, hoods, REALM } from '../src/ripple/client.ts'; -import { DevApi } from '../src/shared/dev-api.ts'; -import { startCluster } from './helpers/cluster.mjs'; -import { memoryStorage, until } from './helpers/demo-server.mjs'; - -test('defeating a borough ripples up every tier across hosts', { timeout: 600_000 }, async () => { - const cluster = await startCluster('ripple'); - const base = cluster.httpUrl; - const ripple = createRippleClient({ api: new DevApi({ base, name: () => 'Hero', storage: memoryStorage() }), base }); - try { - const { client } = ripple; - const [first, ...rest] = hoods(BOROUGHS[0]); - await client.connect(first); - const hello = await ripple.ready; - assert.equal(hello.tier, 'hood'); - assert.equal(hello.tickRate, 30); - await until(() => allZones().every((z) => ripple.tier(z).connected && ripple.tier(z).hello !== undefined), 10_000, 'every tier observed'); - assert.equal(ripple.tier(REALM).hello.tickRate, 2); - assert.equal(ripple.tier(CITY).hello.tickRate, 5); - assert.equal(ripple.tier(BOROUGHS[0]).hello.tickRate, 10); - assert.notEqual(ripple.tier(BOROUGHS[0]).hello.host, hello.host, 'parent on another host'); - assert.equal(ripple.tier(first).boss.hp, ripple.tier(first).boss.max); - await until(() => ripple.weather() === 'storm', 5_000, 'stormy city'); - - const bosses = []; - ripple.onBoss((zone, event) => bosses.push([zone, event.kind])); - for (const hood of [first, ...rest]) { - if (hood !== first) await ripple.enter(hood); - await until(() => client.authority?.zoneId === hood && client.authority.connection.canSendInput(), 5_000, `in ${hood}`); - ripple.smite(); - await until(() => ripple.tier(hood).boss?.hp === 0, 5_000, `${hood} boss down`); - } - await until(() => ripple.tier(BOROUGHS[0]).cleared === 1, 10_000, 'borough cleared'); - await until(() => ripple.tier(CITY).cleared === 1 && ripple.tier(CITY).defeated === 3, 10_000, 'city tally'); - await until(() => ripple.tier(REALM).defeated === 3, 10_000, 'realm tally'); - await until(() => ripple.weather() === 'rain', 5_000, 'weather broadcast down to the players'); - const atBorough = ripple.ripples().filter((r) => r.at === BOROUGHS[0] && r.kind === 'boss_defeated'); - assert.deepEqual(new Set(atBorough.map((r) => r.origin)), new Set([first, ...rest])); - assert.ok(ripple.ripples().some((r) => r.at === CITY && r.kind === 'borough_cleared')); - assert.ok(ripple.ripples().some((r) => r.at === REALM && r.kind === 'borough_cleared' && r.from === CITY)); - assert.ok(bosses.filter(([, kind]) => kind === 'defeated').length >= 3); - assert.equal(ripple.tier(BOROUGHS[1]).cleared, 0, 'the other borough is untouched'); - } finally { - ripple.close(); - await cluster.stop(); - } -}); diff --git a/examples/game/server/sim/src/ripple.rs b/examples/game/server/sim/src/ripple.rs deleted file mode 100644 index 6b53e598..00000000 --- a/examples/game/server/sim/src/ripple.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Boss Ripple's hood input and its client-predicted step (the Arena's -//! movement; attacks are server-owned). - -use distributed_game::{predict, GameInput}; - -use crate::arena::{self, MoveInput, MoveState}; - -/// Hood input: Arena movement + action bits. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, GameInput)] -pub struct HoodInput { - /// Stick x. - #[range(min = -127, max = 127)] - pub mx: i8, - /// Stick y. - #[range(min = -127, max = 127)] - pub my: i8, - /// [`HoodInput::DASH`]. - #[buttons(dash)] - pub buttons: u8, - /// [`HoodInput::ATTACK`] | [`HoodInput::SMITE`]. - #[buttons(attack, smite)] - pub action: u8, -} - -impl HoodInput { - /// The movement part (the Arena's step). - pub fn movement(self) -> MoveInput { - MoveInput { - mx: self.mx, - my: self.my, - buttons: self.buttons, - } - } -} - -/// A fighter's predicted step: the Arena's movement of -/// [`HoodInput::movement`]. -#[predict(HoodStep, kind = "player")] -pub fn hood_step(state: &MoveState, input: &HoodInput, _context: &(), _dt: f32) -> MoveState { - arena::step(*state, input.movement()) -} diff --git a/examples/game/server/src/cluster.rs b/examples/game/server/src/cluster.rs deleted file mode 100644 index f9c6233f..00000000 --- a/examples/game/server/src/cluster.rs +++ /dev/null @@ -1,451 +0,0 @@ -//! Multi-host plumbing for the world demos ([`crate::grid`], -//! [`crate::ripple`]): each host is its own `game-demos ` process with -//! `--host-id` and one `--peer ID=HOST:PORT` per other host. -//! -//! What a host gets on top of [`crate::common`]: -//! -//! - a [`ZoneDirectory`] over every host (the demo chooses: consistent hash, -//! placement by tier …): a Hello for a zone owned elsewhere gets -//! `Redirect`, handoffs and tier-up events cross hosts over the admin -//! routes (`/admin/transfer`, `/admin/child-event`); -//! - the admin secret, derived from the shared ticket secret (all hosts of -//! a world must be started with the same `--secret`); -//! - zone ownership and hibernation on celld (`--celld-url` / `CELLD_URL` -//! with `DISTRIBUTED_INTERNAL_SECRET`, the `examples/game/cells` worker): -//! `CellZoneOwnership`, one `ZoneLease` cell per zone whose alarm declares -//! `zone.lease_expired` when this host stops renewing, and -//! `CellZoneStateStore`, one `ZoneState` cell per zone holding its -//! hibernation snapshot, fenced by the lease token. There is no local -//! fallback: every host of a world shares the same celld; -//! - the zone status read model (`common::zone_status`): every host serves -//! the celld outbox Queue relay (`common::relay`; celld relays to one of -//! them) onto the world's shared projection bus (`{data-dir}/bus.db`), -//! and projects the lease and zone-state cells' outward events into its -//! own `{data-dir}/zone-status-{host}.db`. The grid's world map reads it -//! instead of opening one cell per zone; -//! - guest display names shared with every peer: `/dev/ticket` on any host -//! forwards the new `principal → name` to the others before it answers, -//! so a zone on another host greets the player by name. -//! -//! **Dev only**, like everything under `/dev`. - -use std::future::Future; -use std::net::SocketAddr; -use std::path::PathBuf; -use std::sync::{Arc, OnceLock}; -use std::time::Duration; - -use axum::body::Body; -use axum::extract::{Request, State}; -use axum::http::{HeaderMap, StatusCode}; -use axum::middleware::{self, Next}; -use axum::response::Response; -use axum::routing::post; -use axum::{Json, Router}; -use distributed_game::celld::{CellZoneOwnership, CellZoneStateStore}; -use distributed_game::net::peer::{post_json, ADMIN_SECRET_HEADER}; -use distributed_game::net::{GameServer, NetSim, ServerConfig, TicketKey}; -use distributed_game::world::{ - HostAddr, MemoryZoneStateStore, ZoneDirectory, ZoneOwnership, ZoneStateStore, -}; -use serde_json::{json, Value}; - -use crate::common::relay::{relay_routes, ProjectionBus}; -use crate::common::zone_status::ZoneStatusView; -use crate::common::{ - self, dev, random_hex, server_config, CelldOptions, Demo, DemoArgs, DemoOptions, Guests, - RunningDemo, -}; - -/// CLI options of a multi-host demo. -#[derive(Debug, Clone, clap::Args)] -pub struct ClusterArgs { - #[command(flatten)] - #[allow(missing_docs)] - pub demo: DemoArgs, - /// This host's id (directory entry, ownership holder prefix). - #[arg(long, default_value = "A")] - pub host_id: String, - /// Another host of the same world, `ID=HOST:PORT` (repeatable). Every - /// host must list every other host. - #[arg(long = "peer", value_name = "ID=HOST:PORT")] - pub peers: Vec, - /// Hibernate a zone after it has been Dormant this long (ms); it turns - /// Dormant after half of this without clients. - #[arg(long, default_value_t = 4000)] - pub hibernate_after_ms: u64, - /// celld serving the `zone-lease` and `zone-state` cells - /// (`examples/game/cells`): zone ownership and hibernation snapshots. - #[arg(long, env = "CELLD_URL")] - pub celld_url: String, - /// Internal secret for the celld cells. - #[arg(long, env = "DISTRIBUTED_INTERNAL_SECRET", hide_env_values = true)] - pub internal_secret: String, - /// Directory shared by every host of the world: the projection bus the - /// celld outbox Queue relay publishes onto (`bus.db`) and each host's - /// zone status read model (`zone-status-{host}.db`). Default: - /// `$TMPDIR/game-demos-{demo}`. - #[arg(long, env = "GAME_DEMOS_DATA_DIR")] - pub data_dir: Option, -} - -/// In-process cluster options (tests and [`run_cluster`]). -#[derive(Clone)] -pub struct ClusterOptions { - /// This host's id. - pub host_id: String, - /// Every other host. - pub peers: Vec, - /// Dormant time before hibernation. - pub hibernate_after: Duration, - /// celld holding zone ownership (`ZoneLease`) and hibernation - /// snapshots (`ZoneState`). - pub celld: CelldOptions, - /// The world's shared directory (projection bus, zone status read - /// models). - pub data_dir: PathBuf, -} - -/// Parse `ID=HOST:PORT`. -pub fn parse_peer(spec: &str) -> Result { - let (id, addr) = spec - .split_once('=') - .ok_or_else(|| format!("peer {spec:?}: expected ID=HOST:PORT"))?; - if id.is_empty() || addr.is_empty() { - return Err(format!("peer {spec:?}: expected ID=HOST:PORT")); - } - Ok(HostAddr::new(id, addr)) -} - -/// What a running host knows about its world. -pub struct World { - /// This host. - pub host: HostAddr, - /// Every host, sorted by id (this one included). - pub hosts: Vec, - /// Zone placement. - pub directory: Arc, - /// Hibernation snapshots (celld `ZoneState` cells). - pub state: Arc, - /// Shared admin secret (peer calls). - pub admin_secret: String, - /// Display names (shared with peers by `/dev/ticket`). - pub guests: Guests, - /// Dormant time before hibernation. - pub hibernate_after: Duration, - /// Zone status read model (None for a [`Self::standalone`] world). - pub zone_status: Option, -} - -impl World { - /// A world of one host that is not serving (`Demo::server`; snapshots - /// in memory). - pub fn standalone( - host_id: &str, - hibernate_after: Duration, - guests: Guests, - ) -> Self { - let host = HostAddr::new(host_id, "127.0.0.1:0"); - let hosts = vec![host.clone()]; - Self { - directory: D::directory(&hosts), - host, - hosts, - state: Arc::new(MemoryZoneStateStore::new()), - admin_secret: String::new(), - guests, - hibernate_after, - zone_status: None, - } - } - - /// Hosts other than this one. - pub fn peers(&self) -> impl Iterator + '_ { - self.hosts.iter().filter(|h| h.id != self.host.id) - } - - /// Owner host id of `zone_id` (this host when the directory is silent). - pub fn owner(&self, zone_id: &str) -> String { - self.directory - .locate(zone_id) - .map_or_else(|| self.host.id.clone(), |h| h.id) - } - - /// True when the request carries the admin secret. - pub fn authorized(&self, headers: &HeaderMap) -> bool { - headers - .get(ADMIN_SECRET_HEADER) - .is_some_and(|v| v.as_bytes() == self.admin_secret.as_bytes()) - } - - /// POST `body` to `path` on every peer; `(host id, reply)` per answer. - pub async fn ask_peers(&self, path: &str, body: &Value) -> Vec<(String, Value)> { - let mut out = Vec::new(); - for peer in self.peers() { - let reply = post_json( - &peer.admin_url, - path, - &self.admin_secret, - body, - Duration::from_millis(800), - ) - .await; - if let Ok((200, value)) = reply { - out.push((peer.id.clone(), value)); - } - } - out - } -} - -/// A demo that runs as one host of a multi-host world. -pub trait ClusterDemo: Demo { - /// Zone placement over `hosts` (sorted by id). Must give the same answer - /// on every host. - fn directory(hosts: &[HostAddr]) -> Arc; - - /// Build this host's server (`config` already has the admin secret and - /// `hibernate_after`). Host id, directory, ownership and the state store - /// are attached afterwards; do not clone the server here. - fn cluster_server(config: ServerConfig, world: &World) -> GameServer; - - /// Extra dev routes (mounted with `--dev` only). - fn cluster_routes(_server: GameServer, _world: Arc) -> Router { - Router::new() - } -} - -/// A started host. -pub struct RunningHost { - /// The server, its address and guests. - pub demo: RunningDemo, - /// What the host knows about its world. - pub world: Arc, -} - -/// Admin secret every host of a world derives from the shared ticket key. -pub fn admin_secret(key: &TicketKey) -> String { - key.hmac(b"game-demos|admin")[..16] - .iter() - .map(|b| format!("{b:02x}")) - .collect() -} - -/// Start host `cluster.host_id` of demo `D` on `listener`. -pub async fn spawn_cluster( - listener: tokio::net::TcpListener, - options: DemoOptions, - cluster: ClusterOptions, - signal: impl Future + Send + 'static, -) -> std::io::Result> { - let addr: SocketAddr = listener.local_addr()?; - let host = HostAddr::new(cluster.host_id.clone(), addr.to_string()); - let mut hosts: Vec = cluster - .peers - .iter() - .filter(|p| p.id != host.id) - .cloned() - .collect(); - hosts.push(host.clone()); - hosts.sort(); - let directory = D::directory(&hosts); - let service_id = format!("game-demos-{}", D::NAME); - let state = CellZoneStateStore::new( - cluster.celld.url.clone(), - cluster.celld.secret.clone(), - service_id.clone(), - host.id.clone(), - ) - .map_err(std::io::Error::other)?; - // The relay and the zone status read model (see the module docs). - std::fs::create_dir_all(&cluster.data_dir)?; - let bus = ProjectionBus::open(&format!( - "sqlite:{}", - cluster.data_dir.join("bus.db").display() - )) - .await - .map_err(std::io::Error::other)?; - let zone_status = ZoneStatusView::open( - &format!( - "sqlite:{}?mode=rwc", - cluster - .data_dir - .join(format!("zone-status-{}.db", host.id)) - .display() - ), - bus.pool().clone(), - &format!("zone-status-{}", host.id), - ) - .await - .map_err(std::io::Error::other)?; - let relay = relay_routes(cluster.celld.secret.clone(), Arc::new(OnceLock::from(bus))); - let guests = Guests::default(); - let world = Arc::new(World { - host, - hosts, - directory: directory.clone(), - state: Arc::new(state), - admin_secret: admin_secret(&options.key), - guests: guests.clone(), - hibernate_after: cluster.hibernate_after, - zone_status: Some(zone_status), - }); - - let mut config = server_config(options.key.clone(), options.net_sim); - config.admin_secret = Some(world.admin_secret.clone()); - config.hibernate_after = Some(cluster.hibernate_after); - config.host.dormant_after = cluster.hibernate_after / 2; - let ownership: Arc = Arc::new( - CellZoneOwnership::new( - cluster.celld.url.clone(), - cluster.celld.secret.clone(), - service_id, - world.host.id.clone(), - ) - .map_err(std::io::Error::other)?, - ); - let server = D::cluster_server(config, &world) - .with_host_id(world.host.id.clone()) - .with_directory(directory) - .with_ownership(ownership) - .with_state_store(world.state.clone()); - - let extra = relay.merge(if options.dev { - let tickets = dev::routes::(server.clone(), options.key, guests.clone()).layer( - middleware::from_fn_with_state(world.clone(), share_guest_names), - ); - tickets - .merge( - Router::new() - .route("/dev/cluster/guest", post(receive_guest)) - .with_state(world.clone()), - ) - .merge(D::cluster_routes(server.clone(), world.clone())) - } else { - Router::new() - }); - let task = tokio::spawn(server.clone().serve_with(listener, extra, signal)); - Ok(RunningHost { - demo: RunningDemo { - addr, - server, - guests, - task, - }, - world, - }) -} - -/// CLI entry: bind, print the address, serve until Ctrl-C / SIGTERM. -pub async fn run_cluster(args: ClusterArgs) -> std::io::Result<()> { - let demo = args.demo; - let listener = tokio::net::TcpListener::bind((demo.host.as_str(), demo.port)).await?; - let secret = demo.secret.unwrap_or_else(|| random_hex(64)); - let peers = args - .peers - .iter() - .map(|p| parse_peer(p)) - .collect::, _>>() - .map_err(std::io::Error::other)?; - let options = DemoOptions { - key: TicketKey::new(secret), - dev: demo.dev, - net_sim: NetSim::from_env(), - }; - let cluster = ClusterOptions { - host_id: args.host_id, - peers, - hibernate_after: Duration::from_millis(args.hibernate_after_ms.max(100)), - celld: CelldOptions::new(args.celld_url, args.internal_secret) - .map_err(std::io::Error::other)?, - data_dir: args - .data_dir - .unwrap_or_else(|| std::env::temp_dir().join(format!("game-demos-{}", D::NAME))), - }; - let running = spawn_cluster::(listener, options, cluster, common::shutdown_signal()).await?; - // Parsed by run.sh and the client tests: keep the format. - println!("LISTENING http://{}", running.demo.addr); - let hosts: Vec = running - .world - .hosts - .iter() - .map(|h| format!("{}={}", h.id, h.ws_url)) - .collect(); - println!( - "demo {} · host {} of [{}] · zone {} · dev routes {}", - D::NAME, - running.world.host.id, - hosts.join(", "), - D::DEFAULT_ZONE, - if demo.dev { "ON" } else { "off" } - ); - running.demo.task.await.map_err(std::io::Error::other)? -} - -/// After a successful `/dev/ticket`, tell every peer the guest's display -/// name (awaited, so a redirected Hello finds it on the owning host). -async fn share_guest_names( - State(world): State>, - request: Request, - next: Next, -) -> Response { - let is_ticket = request.uri().path() == "/dev/ticket"; - let response = next.run(request).await; - if !is_ticket || !response.status().is_success() || world.peers().next().is_none() { - return response; - } - let (parts, body) = response.into_parts(); - let bytes = axum::body::to_bytes(body, 64 * 1024) - .await - .unwrap_or_default(); - if let Ok(ticket) = serde_json::from_slice::(&bytes) { - let body = json!({ "principal": ticket["principal"], "name": ticket["name"] }); - world.ask_peers("/dev/cluster/guest", &body).await; - } - Response::from_parts(parts, Body::from(bytes)) -} - -/// `POST /dev/cluster/guest {principal, name}` (admin secret): a peer -/// registered a guest. -async fn receive_guest( - State(world): State>, - headers: HeaderMap, - Json(body): Json, -) -> StatusCode { - if !world.authorized(&headers) { - return StatusCode::UNAUTHORIZED; - } - let (Some(principal), Some(name)) = (body["principal"].as_str(), body["name"].as_str()) else { - return StatusCode::BAD_REQUEST; - }; - if !principal.starts_with("guest-") { - return StatusCode::BAD_REQUEST; - } - world.guests.set_name(principal, sanitize_name(name)); - StatusCode::OK -} - -/// Same rules as the dev ticket endpoint (letters, digits, ` -_`, ≤ 16). -fn sanitize_name(raw: &str) -> String { - raw.chars() - .filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_')) - .take(16) - .collect::() - .trim() - .to_string() -} - -/// 64 random hex characters (OS-seeded SipHash keys; dev quality). -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn peers_parse() { - let peer = parse_peer("B=127.0.0.1:2702").unwrap(); - assert_eq!(peer.id, "B"); - assert_eq!(peer.ws_url, "ws://127.0.0.1:2702"); - assert!(parse_peer("127.0.0.1:2702").is_err()); - assert!(parse_peer("=x").is_err()); - } -} diff --git a/examples/game/server/src/ripple/mod.rs b/examples/game/server/src/ripple/mod.rs deleted file mode 100644 index 1f4a4c4c..00000000 --- a/examples/game/server/src/ripple/mod.rs +++ /dev/null @@ -1,894 +0,0 @@ -//! **Boss Ripple** — hierarchical zones and tier-up event propagation. -//! -//! ```text -//! realm (2 Hz, idle background clock) -//! └ realm.city (5 Hz) -//! ├ realm.city.borough-1 (10 Hz) -//! │ ├ realm.city.borough-1.hood-1 (30 Hz, a boss + players) -//! │ ├ … hood-2, hood-3 -//! └ realm.city.borough-2 … -//! ``` -//! -//! Every neighborhood has a boss players can fight (E near it) or smite -//! (demo button). The boss **stays in its zone**; only its effects travel -//! up as tier-up events (`ctx.emit_upward`, routed by the server through -//! `DottedTopology`, across hosts via `/admin/child-event`): -//! -//! 1. hood: boss falls → `boss_defeated` (to the root); -//! 2. borough: tallies its hoods; when all fell it unlocks and emits -//! `borough_cleared` (to the root); -//! 3. city: weather follows the remaining bosses (storm → rain → clearing -//! → clear) and is broadcast to everyone observing the city — the -//! players in its neighborhoods observe it, so the weather reaches -//! them; with every borough cleared it emits `city_liberated`; -//! 4. realm: counts everything and shows a global banner. -//! -//! A defeated boss respawns after [`RESPAWN`] (`boss_respawned`, which -//! re-locks the borough and darkens the sky again). Tiers tick at -//! different rates (`GameServer::with_host_config`): realm 2 Hz, city -//! 5 Hz, borough 10 Hz, hood 30 Hz. With two hosts, placement alternates -//! by depth (hoods and the city on the first host, boroughs and the realm -//! on the second), so every hop of the ripple crosses hosts. Each tier's -//! state survives hibernation. -//! -//! Entities: 1 `tier` (id 1, every zone), 2 `boss` (id 2, hoods), 3 -//! `player` (1000 + player, hoods; Arena movement fields so the client -//! predicts with [`hood_step`], the Arena movement compiled to WebAssembly — -//! the boss stands on its central pillar). -//! They are `#[derive(Replicated)]` views; client events are the typed -//! [`RippleEvent`] and input the `#[derive(GameInput)]` [`HoodInput`]; the -//! browser client is generated from [`game_surface`]. (Tier-up payloads -//! between zones are server-internal JSON.) - -use std::collections::{BTreeMap, BTreeSet}; -use std::sync::Arc; -use std::time::Duration; - -use distributed_game::net::{GameServer, ServerConfig, ZoneFactory}; -use distributed_game::world::{HostAddr, ZoneDirectory}; -use distributed_game::{ - schema, ChildEvent, ConsumePolicy, Ctx, EntitySchema, GameEvent, GameInput, GameRecord, - GameSurface, LeaveReason, ObserverId, PlayerId, Replicated, TimerId, WorldView, Zone, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use crate::cluster::{ClusterDemo, World}; -use crate::common::{Demo, Guests}; -use game_sim::arena::{self as movement, MoveState}; - -/// Root zone. -pub const REALM: &str = "realm"; -/// The city. -pub const CITY: &str = "realm.city"; -/// Boroughs of the city. -pub const BOROUGHS: [&str; 2] = ["realm.city.borough-1", "realm.city.borough-2"]; -/// Neighborhoods per borough. -pub const HOODS_PER_BOROUGH: usize = 3; -/// Boss hit points. -pub const BOSS_HP: u32 = 240; -/// Damage per hit. -pub const HIT_DAMAGE: u32 = 12; -/// Reach from the boss center (pillar radius + player radius + 1.5 m). -pub const REACH: f32 = 4.5; -/// A defeated boss returns after this long (simulation time of its hood). -pub const RESPAWN: Duration = Duration::from_secs(60); - -/// Kind: tier state (every zone). -/// A tier's tallies (every zone; global). -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "tier", id = 1, global)] -pub struct TierView { - /// Bosses defeated below this tier. - pub defeated: u8, - /// Bosses in the world. - pub total: u8, - /// Boroughs cleared. - pub cleared: u8, - /// The city is liberated. - pub liberated: bool, - /// Index into [`WEATHER`]. - pub weather: u8, - /// Ticks of this zone. - pub clock: u32, - /// Tier-up events received. - pub events: u32, -} - -/// A neighbourhood's boss (global). -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "boss", id = 2, global)] -pub struct BossView { - /// Hit points. - pub hp: u16, - /// Max hit points. - pub max: u16, - /// Who defeated it ("" while alive). - pub by: String, -} - -/// A fighter (the Arena's movement fields; global within the hood). -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "player", id = 3, priority = 2.0, global)] -pub struct FighterView { - /// Position x. - #[field_type(movement::POS)] - #[correct(ease = 150)] - pub x: f32, - /// Position y. - #[field_type(movement::POS)] - #[correct(ease = 150)] - pub y: f32, - /// Velocity x. - #[field_type(movement::VEL)] - #[correct(snap)] - pub vx: f32, - /// Velocity y. - #[field_type(movement::VEL)] - #[correct(snap)] - pub vy: f32, - /// Remaining dash frames. - #[correct(snap)] - pub dash: u8, - /// Remaining dash cooldown frames. - #[correct(snap)] - pub cd: u8, - /// Display name. - #[priority(0.2)] - #[correct(replace)] - pub name: String, -} - -/// The city's weather. -#[derive(Debug, Clone, PartialEq, GameRecord)] -pub struct Weather { - /// One of [`WEATHER`]. - pub weather: String, - /// Bosses still standing. - pub remaining: u32, -} - -/// Boss Ripple's client events. -#[derive(Debug, Clone, PartialEq, GameEvent)] -pub enum RippleEvent { - /// To every joining player and observer. - #[game_event(id = 1)] - Hello { - /// Zone id. - zone: String, - /// `realm` / `city` / `borough` / `hood`. - tier: String, - /// Parent zone. - parent: Option, - /// Child zones. - children: Vec, - /// Host running the zone. - host: String, - /// Tick rate of this tier. - tick_rate: u32, - /// Own fighter (players in hoods). - entity: Option, - /// Weather (the city only). - weather: Option, - /// Current realm banner. - banner: String, - }, - /// A tier-up event arrived at this zone. - #[game_event(id = 2)] - Ripple { - /// Receiving zone. - at: String, - /// Event kind (`boss_defeated` …). - kind: String, - /// Child zone that forwarded it. - from: String, - /// Zone where it started. - origin: String, - /// Tick of the origin zone. - origin_tick: u64, - /// Tick of the receiving zone. - tick: u64, - }, - /// The city's weather changed. - #[game_event(id = 3)] - Weather(Weather), - /// The realm's banner changed. - #[game_event(id = 4)] - Banner { - /// Banner text. - text: String, - }, - /// The hood's boss fell or respawned. - #[game_event(id = 5)] - Boss { - /// `defeated` / `respawned`. - kind: String, - /// Who defeated it. - by: Option, - }, -} - -/// Boss Ripple's client surface. -pub fn game_surface() -> GameSurface { - GameSurface::for_zone::("ripple") - .events::() - .predict::() - .constant("ripple_world", &ripple_world()) - .constant("arena_floor", &game_sim::arena::arena_floor()) -} - -/// Kind id of [`TierView`] (tests mirror fields by it). -pub const KIND_TIER: u16 = TierView::KIND_ID; -/// Input action bit: attack (needs [`REACH`]). -pub const ACTION_ATTACK: u8 = HoodInput::ATTACK; -/// Input action bit: smite (demo shortcut: defeats the boss from anywhere). -pub const ACTION_SMITE: u8 = HoodInput::SMITE; -const ATTACK_COOLDOWN: u8 = 8; -const MAX_CREDITS: u8 = 4; -/// Weather names by index (city). -pub const WEATHER: [&str; 4] = ["storm", "rain", "clearing", "clear"]; - -/// Tier of a zone. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum TierKind { - /// Province / root. - Realm, - /// City. - City, - /// Borough. - Borough, - /// Neighborhood (has a boss). - Hood, -} - -impl TierKind { - /// Depth in the hierarchy (realm = 1). - pub fn depth(self) -> usize { - match self { - Self::Realm => 1, - Self::City => 2, - Self::Borough => 3, - Self::Hood => 4, - } - } - - /// Lowercase name. - pub fn name(self) -> &'static str { - match self { - Self::Realm => "realm", - Self::City => "city", - Self::Borough => "borough", - Self::Hood => "hood", - } - } - - /// (tick, patch, idle) rates. - pub fn rates(self) -> (u32, u32, Option) { - match self { - Self::Realm => (2, 2, Some(1)), - Self::City => (5, 5, Some(1)), - Self::Borough => (10, 10, Some(1)), - Self::Hood => (30, 20, Some(2)), - } - } -} - -/// A borough and its neighborhoods. -#[derive(Debug, Clone, PartialEq, GameRecord)] -pub struct BoroughLayout { - /// Borough zone id. - pub id: String, - /// Neighborhood zone ids. - pub hoods: Vec, -} - -/// The zone hierarchy and weather names (generated `RIPPLE_WORLD`). -#[derive(Debug, Clone, PartialEq, GameRecord)] -pub struct RippleWorld { - /// Root zone. - pub realm: String, - /// The city. - pub city: String, - /// Boroughs with their neighborhoods. - pub boroughs: Vec, - /// [`WEATHER`] names by index. - pub weather: Vec, -} - -/// The `RIPPLE_WORLD` constant. -pub fn ripple_world() -> RippleWorld { - RippleWorld { - realm: REALM.into(), - city: CITY.into(), - boroughs: BOROUGHS - .iter() - .map(|b| BoroughLayout { - id: b.to_string(), - hoods: hoods(b), - }) - .collect(), - weather: WEATHER.map(String::from).to_vec(), - } -} - -/// Neighborhoods of a borough. -pub fn hoods(borough: &str) -> Vec { - (1..=HOODS_PER_BOROUGH) - .map(|i| format!("{borough}.hood-{i}")) - .collect() -} - -/// Every zone of the hierarchy, root first. -pub fn all_zones() -> Vec { - let mut zones = vec![REALM.to_string(), CITY.to_string()]; - for b in BOROUGHS { - zones.push(b.to_string()); - zones.extend(hoods(b)); - } - zones -} - -/// Total bosses. -pub fn total_bosses() -> usize { - BOROUGHS.len() * HOODS_PER_BOROUGH -} - -/// Tier of a known zone id. -pub fn tier_of(zone_id: &str) -> Option { - match zone_id { - REALM => Some(TierKind::Realm), - CITY => Some(TierKind::City), - z if BOROUGHS.contains(&z) => Some(TierKind::Borough), - z => BOROUGHS - .iter() - .any(|b| hoods(b).iter().any(|h| h == z)) - .then_some(TierKind::Hood), - } -} - -fn children(zone_id: &str) -> Vec { - match tier_of(zone_id) { - Some(TierKind::Realm) => vec![CITY.into()], - Some(TierKind::City) => BOROUGHS.iter().map(|b| b.to_string()).collect(), - Some(TierKind::Borough) => hoods(zone_id), - _ => Vec::new(), - } -} - -/// Placement: depth-alternating over the sorted hosts (hood → hosts[0], -/// borough → hosts[1], city → hosts[0], realm → hosts[1] with two hosts). -pub struct TierPlacement { - hosts: Vec, -} - -impl TierPlacement { - /// Over `hosts` (sorted by id). - pub fn new(hosts: &[HostAddr]) -> Self { - Self { - hosts: hosts.to_vec(), - } - } -} - -impl ZoneDirectory for TierPlacement { - fn locate(&self, zone_id: &str) -> Option { - let n = self.hosts.len(); - if n == 0 { - return None; - } - let depth = tier_of(zone_id).map_or(4, TierKind::depth); - self.hosts.get((4 - depth) % n).cloned() - } - - fn host(&self, host_id: &str) -> Option { - self.hosts.iter().find(|h| h.id == host_id).cloned() - } - - fn hosts(&self) -> Vec { - self.hosts.clone() - } -} - -pub use game_sim::ripple::{hood_step, HoodInput, HoodStep}; - -/// Timers. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RippleTimer { - /// The boss comes back. - Respawn, -} - -struct Fighter { - entity: u32, - name: String, - state: MoveState, - credits: u8, - cooldown: u8, -} - -/// Persistent tier state (also the hibernation snapshot). -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -pub struct TierState { - /// Boss hit points (hoods). - pub hp: u32, - /// Who landed the last blow (hoods, while defeated). - pub defeated_by: Option, - /// Hoods whose boss is down, as seen by this tier. - pub defeated: BTreeSet, - /// Boroughs cleared, as seen by this tier (city, realm). - pub cleared: BTreeSet, - /// City liberated (city, realm). - pub liberated: bool, - /// Times the city was liberated (realm). - pub liberations: u32, - /// Child events received. - pub events: u32, - /// Realm banner. - pub banner: String, -} - -/// Zone construction parameters. -#[derive(Clone)] -pub struct RippleConfig { - /// Display names. - pub guests: Guests, - /// Host running the zone. - pub host_id: String, - /// Tier. - pub tier: TierKind, -} - -/// One zone of the hierarchy (every tier). -pub struct Ripple { - config: RippleConfig, - state: TierState, - fighters: BTreeMap, - respawn: Option, - /// Ticks simulated by this activation (the tier's clock). - ticks: u32, -} - -impl Ripple { - /// Persistent state (tests, tooling). - pub fn state(&self) -> &TierState { - &self.state - } - - /// Tier. - pub fn tier(&self) -> TierKind { - self.config.tier - } - - fn weather(&self) -> usize { - let remaining = total_bosses() - self.state.defeated.len().min(total_bosses()); - match remaining { - 0 => 3, - 1 | 2 => 2, - 3 | 4 => 1, - _ => 0, - } - } - - fn weather_record(&self) -> Weather { - Weather { - weather: WEATHER[self.weather()].into(), - remaining: (total_bosses() - self.state.defeated.len().min(total_bosses())) as u32, - } - } - - fn hello(&self, ctx: &Ctx<'_, Self>, entity: Option) -> RippleEvent { - let tier = self.config.tier; - RippleEvent::Hello { - zone: ctx.zone_id().into(), - tier: tier.name().into(), - parent: ctx.zone_id().rsplit_once('.').map(|(p, _)| p.to_string()), - children: children(ctx.zone_id()), - host: self.config.host_id.clone(), - tick_rate: tier.rates().0, - entity, - weather: (tier == TierKind::City).then(|| self.weather_record()), - banner: self.state.banner.clone(), - } - } - - fn greet(&self, ctx: &mut Ctx<'_, Self>, to: distributed_game::Viewer, entity: Option) { - let hello = self.hello(ctx, entity); - ctx.send(to, &hello); - } - - fn defeat(&mut self, ctx: &mut Ctx<'_, Self>, by: String) { - self.state.hp = 0; - self.state.defeated_by = Some(by.clone()); - self.state.defeated.insert(ctx.zone_id().to_string()); - let payload = json!({ "hood": ctx.zone_id(), "by": by }); - ctx.emit_upward( - "boss_defeated", - payload.to_string().into_bytes(), - ChildEvent::TO_ROOT, - ); - ctx.broadcast(&RippleEvent::Boss { - kind: "defeated".into(), - by: Some(by), - }); - self.respawn = Some(ctx.set_timeout(RESPAWN, RippleTimer::Respawn)); - } - - fn hood_tick(&mut self, ctx: &mut Ctx<'_, Self>) { - let mut blows: Vec<(u32, String)> = Vec::new(); - for (id, f) in &mut self.fighters { - f.credits = (f.credits + 1).min(MAX_CREDITS); - f.cooldown = f.cooldown.saturating_sub(1); - while f.credits > 0 { - let Some(input) = ctx.take_inputs(*id, ConsumePolicy::Next).pop() else { - break; - }; - let input = input.input; - // The declared predicted step: what the client runs as WASM. - f.state = hood_step(&f.state, &input, &(), movement::DT); - f.credits -= 1; - if input.action & ACTION_SMITE != 0 { - blows.push((u32::MAX, f.name.clone())); - } else if input.action & ACTION_ATTACK != 0 && f.cooldown == 0 { - let d = (f.state.x * f.state.x + f.state.y * f.state.y).sqrt(); - if d <= REACH { - f.cooldown = ATTACK_COOLDOWN; - blows.push((HIT_DAMAGE, f.name.clone())); - } - } - } - } - for (damage, by) in blows { - if self.state.hp == 0 { - break; - } - self.state.hp = self.state.hp.saturating_sub(damage); - if self.state.hp == 0 { - self.defeat(ctx, by); - } - } - } - - fn on_child(&mut self, ctx: &mut Ctx<'_, Self>, event: &ChildEvent) { - let origin = event.origin_zone.clone(); - let tier = self.config.tier; - let weather_before = self.weather(); - match event.kind.as_str() { - "boss_defeated" => { - self.state.defeated.insert(origin.clone()); - } - "boss_respawned" => { - self.state.defeated.remove(&origin); - } - "borough_cleared" => { - self.state.cleared.insert(origin.clone()); - } - "borough_lost" => { - self.state.cleared.remove(&origin); - } - _ => {} - } - match tier { - TierKind::Borough => { - let all = hoods(ctx.zone_id()) - .iter() - .all(|h| self.state.defeated.contains(h)); - let was = self.state.cleared.contains(ctx.zone_id()); - let me = ctx.zone_id().to_string(); - if all && !was { - self.state.cleared.insert(me.clone()); - let payload = json!({ "borough": me }).to_string().into_bytes(); - ctx.emit_upward("borough_cleared", payload, ChildEvent::TO_ROOT); - } else if !all && was { - self.state.cleared.remove(&me); - let payload = json!({ "borough": me }).to_string().into_bytes(); - ctx.emit_upward("borough_lost", payload, ChildEvent::TO_ROOT); - } - } - TierKind::City => { - let liberated = BOROUGHS.iter().all(|b| self.state.cleared.contains(*b)); - if liberated != self.state.liberated { - self.state.liberated = liberated; - let kind = if liberated { - "city_liberated" - } else { - "city_fallen" - }; - let payload = json!({ "city": ctx.zone_id() }).to_string().into_bytes(); - ctx.emit_upward(kind, payload, 1); - } - if self.weather() != weather_before { - let weather = self.weather_record(); - ctx.broadcast(&RippleEvent::Weather(weather)); - } - } - TierKind::Realm => { - let banner = match event.kind.as_str() { - "city_liberated" => { - self.state.liberated = true; - self.state.liberations += 1; - Some(format!( - "The city is liberated! (liberation #{})", - self.state.liberations - )) - } - "city_fallen" => { - self.state.liberated = false; - Some("Darkness returns to the city".to_string()) - } - _ => None, - }; - if let Some(text) = banner { - self.state.banner = text.clone(); - ctx.broadcast(&RippleEvent::Banner { text }); - } - } - TierKind::Hood => {} - } - self.state.events += 1; - let ripple = RippleEvent::Ripple { - at: ctx.zone_id().into(), - kind: event.kind.clone(), - from: event.from_zone.clone(), - origin: event.origin_zone.clone(), - origin_tick: event.origin_tick, - tick: ctx.tick(), - }; - ctx.broadcast(&ripple); - } -} - -impl Zone for Ripple { - type Config = RippleConfig; - type Input = HoodInput; - type Timer = RippleTimer; - type Message = distributed_game::NoMessages; - - fn schema() -> EntitySchema { - schema![TierView, BossView, FighterView] - } - - fn on_create(_ctx: &mut Ctx<'_, Self>, config: RippleConfig) -> Self { - Self { - config, - state: TierState { - hp: BOSS_HP, - ..TierState::default() - }, - fighters: BTreeMap::new(), - respawn: None, - ticks: 0, - } - } - - fn on_join(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId) { - let principal = ctx - .player(player) - .map(|p| p.principal.clone()) - .unwrap_or_default(); - let name = self - .config - .guests - .name(&principal) - .unwrap_or_else(|| format!("Guest {}", principal.get(6..10).unwrap_or(""))); - let entity = 1000 + player.0; - if self.config.tier == TierKind::Hood { - const SPOTS: [(f32, f32); 4] = [(-6.0, -6.0), (6.0, -6.0), (6.0, 6.0), (-6.0, 6.0)]; - let (x, y) = SPOTS[player.0 as usize % SPOTS.len()]; - self.fighters.insert( - player, - Fighter { - entity, - name, - state: movement::canonicalize(MoveState { - x, - y, - ..MoveState::default() - }), - credits: MAX_CREDITS, - cooldown: 0, - }, - ); - } - let entity = (self.config.tier == TierKind::Hood).then_some(entity); - self.greet(ctx, player.into(), entity); - } - - fn on_auth( - &mut self, - _ctx: &mut Ctx<'_, Self>, - request: &distributed_game::JoinRequest<'_>, - ) -> distributed_game::Admission { - // Only neighborhoods are playable; upper tiers are observed. - if self.config.tier != TierKind::Hood - && request.claims.role == distributed_game::Role::Player - { - return distributed_game::Admission::Reject("observe this tier".into()); - } - distributed_game::Admission::Admit - } - - fn on_leave(&mut self, _ctx: &mut Ctx<'_, Self>, player: PlayerId, _reason: LeaveReason) { - self.fighters.remove(&player); - } - - fn on_observe(&mut self, ctx: &mut Ctx<'_, Self>, observer: ObserverId) { - self.greet(ctx, observer.into(), None); - } - - fn decode_input(&self, _player: PlayerId, payload: &[u8]) -> Option { - HoodInput::from_payload(payload) - } - - fn on_tick(&mut self, ctx: &mut Ctx<'_, Self>) { - self.ticks = self.ticks.wrapping_add(1); - if self.config.tier == TierKind::Hood { - self.hood_tick(ctx); - } - } - - fn on_timer(&mut self, ctx: &mut Ctx<'_, Self>, _id: TimerId, timer: RippleTimer) { - match timer { - RippleTimer::Respawn => { - self.respawn = None; - self.state.hp = BOSS_HP; - self.state.defeated_by = None; - self.state.defeated.remove(ctx.zone_id()); - let payload = json!({ "hood": ctx.zone_id() }).to_string().into_bytes(); - ctx.emit_upward("boss_respawned", payload, ChildEvent::TO_ROOT); - ctx.broadcast(&RippleEvent::Boss { - kind: "respawned".into(), - by: None, - }); - } - } - } - - fn on_child_event(&mut self, ctx: &mut Ctx<'_, Self>, event: &ChildEvent) { - self.on_child(ctx, event); - } - - fn wants_background(&self) -> bool { - // The realm keeps its slow clock running with nobody watching. - self.config.tier == TierKind::Realm - } - - fn replicate(&self, out: &mut WorldView) { - let s = &self.state; - let tier = TierView { - defeated: s.defeated.len() as u8, - total: total_bosses() as u8, - cleared: s.cleared.len() as u8, - liberated: s.liberated, - weather: self.weather() as u8, - clock: self.ticks, - events: s.events, - }; - out.add(1, &tier); - if self.config.tier != TierKind::Hood { - return; - } - let boss = BossView { - hp: s.hp as u16, - max: BOSS_HP as u16, - by: s.defeated_by.clone().unwrap_or_default(), - }; - out.add(2, &boss); - for f in self.fighters.values() { - let m = f.state; - let view = FighterView { - x: m.x, - y: m.y, - vx: m.vx, - vy: m.vy, - dash: m.dash, - cd: m.cd, - name: f.name.clone(), - }; - out.add(f.entity, &view); - } - } - - fn hibernate(&self) -> Option> { - serde_json::to_vec(&self.state).ok() - } - - fn on_restore(&mut self, ctx: &mut Ctx<'_, Self>, state: &[u8], _saved_version: &str) { - if let Ok(state) = serde_json::from_slice::(state) { - self.state = state; - } - if self.config.tier == TierKind::Hood && self.state.hp == 0 { - self.respawn = Some(ctx.set_timeout(RESPAWN, RippleTimer::Respawn)); - } - } -} - -/// Bot / test payload. -pub fn encode_input(mx: i8, my: i8, action: u8) -> Vec { - HoodInput { - mx, - my, - buttons: 0, - action, - } - .to_payload() -} - -/// Demo registration (`game-demos ripple`). -pub struct RippleDemo; - -impl Demo for RippleDemo { - type Zone = Ripple; - const NAME: &'static str = "ripple"; - const DEFAULT_ZONE: &'static str = "realm.city.borough-1.hood-1"; - - fn hosts_zone(zone_id: &str) -> bool { - tier_of(zone_id).is_some() - } - - fn server(config: ServerConfig, guests: Guests) -> GameServer { - let world = World::standalone::("local", Duration::from_secs(20), guests); - Self::cluster_server(config, &world) - } -} - -impl ClusterDemo for RippleDemo { - fn directory(hosts: &[HostAddr]) -> Arc { - Arc::new(TierPlacement::new(hosts)) - } - - fn cluster_server(mut config: ServerConfig, world: &World) -> GameServer { - config.host.max_observers = 64; - let guests = world.guests.clone(); - let host_id = world.host.id.clone(); - GameServer::new( - config, - ZoneFactory::sync(move |activation| { - let zone_id = activation.zone_id.as_str(); - tier_of(zone_id).map(|tier| RippleConfig { - guests: guests.clone(), - host_id: host_id.clone(), - tier, - }) - }), - ) - .with_host_config(|zone_id, host| { - if let Some(tier) = tier_of(zone_id) { - let (tick, patch, idle) = tier.rates(); - host.tick_rate = tick; - host.patch_rate = patch; - host.idle_tick_rate = idle; - } - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn hierarchy_and_placement() { - assert_eq!(all_zones().len(), 2 + 2 + 6); - assert_eq!(tier_of("realm.city.borough-2.hood-3"), Some(TierKind::Hood)); - assert_eq!(tier_of("realm.city.borough-3"), None); - let hosts = [HostAddr::new("A", "a:1"), HostAddr::new("B", "b:1")]; - let place = TierPlacement::new(&hosts); - let owner = |z: &str| place.locate(z).unwrap().id; - assert_eq!(owner("realm.city.borough-1.hood-1"), "A"); - assert_eq!(owner("realm.city.borough-1"), "B"); - assert_eq!(owner(CITY), "A"); - assert_eq!(owner(REALM), "B"); - let one = TierPlacement::new(&hosts[..1]); - assert!(all_zones().iter().all(|z| one.locate(z).unwrap().id == "A")); - } - - #[test] - fn input_round_trips() { - let bytes = encode_input(-128, 5, 0xff); - let input = HoodInput::from_payload(&bytes).unwrap(); - assert_eq!(input.movement().mx, -127); - assert_eq!(input.action, ACTION_ATTACK | ACTION_SMITE); - assert_eq!(HoodInput::from_payload(&bytes[..3]), None); - } -} diff --git a/examples/game/server/tests/ripple_smoke.rs b/examples/game/server/tests/ripple_smoke.rs deleted file mode 100644 index 4db41702..00000000 --- a/examples/game/server/tests/ripple_smoke.rs +++ /dev/null @@ -1,234 +0,0 @@ -//! Boss Ripple smoke test: two in-process hosts, placement alternating by -//! depth so every tier-up hop crosses hosts. Defeating bosses must cascade -//! hood → borough → city → realm with the right state at every tier. - -mod support; - -use std::time::Duration; - -use distributed_game::{FieldValue, GameEvent, Role, ServerMessage}; -use game_demos::ripple::{ - self, encode_input, RippleDemo, RippleEvent, ACTION_ATTACK, ACTION_SMITE, BOROUGHS, CITY, REALM, -}; -use support::{eventually, join, start_cluster, ticket, Client}; - -/// Field `i` of the tier entity (id 1) as u32. -fn tier_field(client: &Client, i: usize) -> u32 { - match client.mirror.entities.get(&1).map(|e| &e.fields[i]) { - Some(FieldValue::U(v)) => *v, - Some(FieldValue::Bool(b)) => u32::from(*b), - _ => u32::MAX, - } -} - -/// Wait for the zone's hello: `(zone, tier, entity)`. -async fn hello(c: &mut Client) -> (String, String, Option) { - match c - .wait_event::(|e| matches!(e, RippleEvent::Hello { .. })) - .await - { - RippleEvent::Hello { - zone, tier, entity, .. - } => (zone, tier, entity), - _ => unreachable!(), - } -} - -/// Wait for the next boss event's kind. -async fn boss(c: &mut Client) -> String { - match c - .wait_event::(|e| matches!(e, RippleEvent::Boss { .. })) - .await - { - RippleEvent::Boss { kind, .. } => kind, - _ => unreachable!(), - } -} - -async fn observe(base: &str, zone: &str) -> Client { - let mut c = join(base, zone, &ticket("guest-watch", zone, Role::Observer)) - .await - .unwrap(); - let (at, _, _) = hello(&mut c).await; - assert_eq!(at, zone); - c.until("tier entity", |c| c.mirror.entities.contains_key(&1)) - .await; - c -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn boss_defeats_ripple_up_every_tier_across_hosts() { - let cluster = start_cluster::(&["A", "B"], Duration::from_secs(20)).await; - let world = &cluster.hosts[0].world; - assert_eq!(world.owner("realm.city.borough-1.hood-1"), "A"); - assert_eq!(world.owner(BOROUGHS[0]), "B"); - assert_eq!(world.owner(CITY), "A"); - assert_eq!(world.owner(REALM), "B"); - let base = cluster.host("A").demo.ws_url(); - - let mut realm = observe(&base, REALM).await; - let mut city = observe(&base, CITY).await; - let mut borough = observe(&base, BOROUGHS[0]).await; - assert_eq!(realm.redirects, 1, "realm lives on B"); - assert_eq!(realm.welcome.tick_rate, 2, "slow realm clock"); - assert_eq!(city.welcome.tick_rate, 5); - assert_eq!(borough.welcome.tick_rate, 10); - // Upper tiers are observed, never joined. - let refused = join(&base, CITY, &ticket("guest-x", CITY, Role::Player)).await; - assert!(matches!(refused, Err(ServerMessage::Error { .. }))); - - // Fight the first boss by hand: walk to it and attack. - let hood1 = "realm.city.borough-1.hood-1"; - let mut fighter = join(&base, hood1, &ticket("guest-fighter", hood1, Role::Player)) - .await - .unwrap(); - assert_eq!(fighter.welcome.tick_rate, 30, "hot neighborhood"); - let (_, tier, entity) = hello(&mut fighter).await; - assert_eq!(tier, "hood"); - let me = entity.expect("own fighter"); - let deadline = tokio::time::Instant::now() + Duration::from_secs(20); - let mut defeated = None; - while defeated.is_none() { - assert!(tokio::time::Instant::now() < deadline, "boss not defeated"); - // Steer toward the boss at the center, attacking once in reach. - let (x, y) = match fighter - .mirror - .entities - .get(&me) - .map(|e| (&e.fields[0], &e.fields[1])) - { - Some((FieldValue::F(x), FieldValue::F(y))) => (*x, *y), - _ => (0.0, 0.0), - }; - let d = (x * x + y * y).sqrt().max(1e-3); - let k = if d > 3.5 { 127.0 / d } else { 0.0 }; - fighter - .input(encode_input((-x * k) as i8, (-y * k) as i8, ACTION_ATTACK)) - .await; - while let Some(msg) = fighter.next(Duration::from_millis(33)).await { - if let ServerMessage::Event { type_id, payload } = msg { - if let Some(RippleEvent::Boss { kind, .. }) = - RippleEvent::decode_event(type_id, &payload).unwrap() - { - defeated = Some(kind); - } - } - } - } - assert_eq!(defeated.unwrap(), "defeated"); - - // The rest of borough-1 by smiting. - for hood in &ripple::hoods(BOROUGHS[0])[1..] { - let mut c = join(&base, hood, &ticket("guest-smiter", hood, Role::Player)) - .await - .unwrap(); - hello(&mut c).await; - c.input(encode_input(0, 0, ACTION_SMITE)).await; - assert_eq!(boss(&mut c).await, "defeated"); - let state = cluster - .host("A") - .demo - .server - .inspect(hood, |h| h.zone().state().hp) - .await; - assert_eq!(state, Some(0)); - } - borough - .until("borough tallied all three", |c| { - tier_field(c, 0) == 3 && tier_field(c, 2) == 1 - }) - .await; - let ripples: Vec<(String, String, String)> = borough - .seen_events::() - .into_iter() - .filter_map(|e| match e { - RippleEvent::Ripple { - at, kind, origin, .. - } => Some((at, kind, origin)), - _ => None, - }) - .collect(); - city.until("city saw the borough cleared", |c| { - tier_field(c, 2) == 1 && tier_field(c, 0) == 3 - }) - .await; - assert_eq!(tier_field(&city, 4), 1, "weather: rain with 3 bosses left"); - realm - .until("realm counted", |c| { - tier_field(c, 0) == 3 && tier_field(c, 2) == 1 - }) - .await; - assert_eq!(tier_field(&realm, 3), 0, "city not liberated yet"); - - // Borough-2 falls too: the city is liberated and the realm says so. - for hood in ripple::hoods(BOROUGHS[1]) { - let mut c = join(&base, &hood, &ticket("guest-smiter", &hood, Role::Player)) - .await - .unwrap(); - hello(&mut c).await; - c.input(encode_input(0, 0, ACTION_SMITE)).await; - boss(&mut c).await; - } - let banner = match realm - .wait_event::(|e| matches!(e, RippleEvent::Banner { .. })) - .await - { - RippleEvent::Banner { text } => text, - _ => unreachable!(), - }; - assert!(banner.contains("liberated"), "{banner}"); - realm - .until("realm state", |c| { - tier_field(c, 0) == 6 && tier_field(c, 2) == 2 && tier_field(c, 3) == 1 - }) - .await; - city.until("city liberated", |c| { - tier_field(c, 3) == 1 && tier_field(c, 4) == 3 - }) - .await; - let weather: Vec = city - .seen_events::() - .into_iter() - .filter_map(|e| match e { - RippleEvent::Weather(w) => Some(w.weather), - _ => None, - }) - .collect(); - assert_eq!( - weather.last().map(String::as_str), - Some("clear"), - "{weather:?}" - ); - assert!(weather.iter().any(|w| w == "rain"), "{weather:?}"); - - // Borough-1's ripple events name each hood as origin and the borough as receiver. - let origins: std::collections::BTreeSet = ripples - .iter() - .filter(|(_, kind, _)| kind == "boss_defeated") - .map(|(_, _, origin)| origin.clone()) - .collect(); - assert!(origins.len() >= 2, "{ripples:?}"); - assert!(ripples.iter().all(|(at, _, _)| at == BOROUGHS[0])); - - // The cascade's state lives where each tier runs. - let city_state = cluster - .host("A") - .demo - .server - .inspect(CITY, |h| h.zone().state().clone()) - .await - .unwrap(); - assert!(city_state.liberated); - assert_eq!(city_state.cleared.len(), 2); - eventually("realm state on B", || { - let server = cluster.host("B").demo.server.clone(); - async move { - server - .inspect(REALM, |h| h.zone().state().liberations) - .await - == Some(1) - } - }) - .await; - cluster.stop().await; -} diff --git a/examples/game/server/tests/support/mod.rs b/examples/game/server/tests/support/mod.rs deleted file mode 100644 index 6070a9d9..00000000 --- a/examples/game/server/tests/support/mod.rs +++ /dev/null @@ -1,315 +0,0 @@ -//! Shared helpers for the multi-host smoke tests: start N in-process hosts -//! of a cluster demo on ephemeral ports, sharing one private `celld dev` -//! (zone leases and hibernation snapshots; `game_demos::devcelld`), and a -//! minimal protocol client that follows Redirects. -#![allow(dead_code)] - -use std::net::SocketAddr; -use std::time::Duration; - -use distributed_game::net::TicketKey; -use distributed_game::protocol::{Hello, Welcome}; -use distributed_game::world::HostAddr; -use distributed_game::{ - ClientMessage, ClientMirror, GameEvent, Role, ServerMessage, TicketClaims, PROTOCOL_VERSION, -}; -use futures_util::{SinkExt, StreamExt}; -use game_demos::cluster::{spawn_cluster, ClusterDemo, ClusterOptions, RunningHost}; -use game_demos::common::DemoOptions; -use game_demos::devcelld::DevCelld; -use serde_json::Value; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; -use tokio_tungstenite::tungstenite::Message; -use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; - -pub const SECRET: &str = "cluster-smoke-secret"; - -pub type Ws = WebSocketStream>; - -pub struct Cluster { - pub hosts: Vec>, - stops: Vec>, - pub celld: DevCelld, -} - -impl Cluster { - /// Host by id. - pub fn host(&self, id: &str) -> &RunningHost { - self.hosts - .iter() - .find(|h| h.world.host.id == id) - .expect("host") - } - - /// The host owning `zone`. - pub fn owner(&self, zone: &str) -> &RunningHost { - self.host(&self.hosts[0].world.owner(zone)) - } - - pub async fn stop(self) { - for stop in self.stops { - let _ = stop.send(()); - } - for host in self.hosts { - let _ = host.demo.task.await; - } - drop(self.celld); - } -} - -/// Start hosts `ids` (all peers of each other) on 127.0.0.1:0. -pub async fn start_cluster(ids: &[&str], hibernate_after: Duration) -> Cluster { - let mut listeners = Vec::new(); - for id in ids { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - listeners.push((id.to_string(), addr, listener)); - } - let all: Vec = listeners - .iter() - .map(|(id, addr, _)| HostAddr::new(id.clone(), addr.to_string())) - .collect(); - // celld relays its outbox Queue (zone lease and state events) to the - // first host; every host projects the shared bus into its own zone - // status read model. - let relay = format!( - "http://{}{}", - listeners[0].1, - distributed::bus::CELLD_QUEUE_RELAY_PATH - ); - let celld = DevCelld::start(Some(relay)).unwrap_or_else(|error| panic!("celld dev: {error}")); - let data_dir = std::env::temp_dir().join(format!( - "cluster-smoke-{}", - game_demos::common::random_hex(10) - )); - let mut hosts = Vec::new(); - let mut stops = Vec::new(); - for (id, _, listener) in listeners { - let (stop, stopped) = tokio::sync::oneshot::channel::<()>(); - let options = ClusterOptions { - host_id: id, - peers: all.clone(), - hibernate_after, - celld: celld.options(), - data_dir: data_dir.clone(), - }; - let host = spawn_cluster::(listener, DemoOptions::dev(SECRET), options, async move { - let _ = stopped.await; - }) - .await - .unwrap(); - hosts.push(host); - stops.push(stop); - } - Cluster { - hosts, - stops, - celld, - } -} - -pub fn ticket(principal: &str, zone: &str, role: Role) -> String { - static NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let n = NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - TicketKey::new(SECRET).sign(&TicketClaims { - principal: principal.into(), - zone: zone.into(), - expires_at: 4_000_000_000, - nonce: format!("n-{principal}-{n}"), - role, - name: None, - extensions: Default::default(), - }) -} - -/// A connected protocol client. -pub struct Client { - pub ws: Ws, - pub mirror: ClientMirror, - pub welcome: Welcome, - /// URL that finally accepted the Hello. - pub url: String, - /// Redirects followed to get there. - pub redirects: u32, - pub seq: u32, - /// Messages received after Welcome but not yet consumed. - pub backlog: Vec, - /// Every game Event received (type id, payload), in order. - pub events: Vec<(u32, Vec)>, -} - -async fn hello(ws: &mut Ws, ticket: &str) { - let msg = ClientMessage::Hello(Hello { - protocol_version: PROTOCOL_VERSION, - content_version: String::new(), - capabilities: 0, - ticket: ticket.to_string(), - reconnect_token: String::new(), - }); - ws.send(Message::Binary(msg.encode().into())).await.unwrap(); -} - -/// Next decoded server message, None on close / timeout. -pub async fn next(ws: &mut Ws, mirror: &mut ClientMirror, wait: Duration) -> Option { - loop { - let frame = tokio::time::timeout(wait, ws.next()).await.ok()??; - match frame { - Ok(Message::Binary(bytes)) => return Some(mirror.apply(&bytes).unwrap()), - Ok(Message::Close(_)) | Err(_) => return None, - Ok(_) => continue, - } - } -} - -/// Connect to `base/zones/zone`, following Redirects, until Welcome. -#[allow(clippy::result_large_err)] -pub async fn join(base: &str, zone: &str, ticket: &str) -> Result { - let mut url = format!("{base}/zones/{zone}"); - let mut redirects = 0; - for _ in 0..4 { - let (mut ws, _) = connect_async(&url).await.unwrap(); - hello(&mut ws, ticket).await; - let mut mirror = ClientMirror::new(); - loop { - match next(&mut ws, &mut mirror, Duration::from_secs(5)) - .await - .expect("reply to Hello") - { - ServerMessage::Welcome(welcome) => { - return Ok(Client { - ws, - mirror, - welcome, - url, - redirects, - seq: 0, - backlog: Vec::new(), - events: Vec::new(), - }) - } - ServerMessage::Redirect { url: to, .. } => { - url = to; - redirects += 1; - break; - } - ServerMessage::Pong { .. } => continue, - other => return Err(other), - } - } - } - panic!("redirect loop") -} - -impl Client { - pub async fn input(&mut self, payload: Vec) { - self.seq += 1; - let msg = ClientMessage::Input { - seq: self.seq, - client_tick: 0, - payload, - }; - self.ws - .send(Message::Binary(msg.encode().into())) - .await - .unwrap(); - } - - pub async fn leave(&mut self) { - let _ = self - .ws - .send(Message::Binary(ClientMessage::Leave.encode().into())) - .await; - } - - /// Next message (backlog first). - pub async fn next(&mut self, wait: Duration) -> Option { - if !self.backlog.is_empty() { - return Some(self.backlog.remove(0)); - } - let msg = next(&mut self.ws, &mut self.mirror, wait).await; - if let Some(ServerMessage::Event { type_id, payload }) = &msg { - self.events.push((*type_id, payload.clone())); - } - msg - } - - /// Typed events of `E` received so far. - pub fn seen_events(&self) -> Vec { - self.events - .iter() - .filter_map(|(t, p)| E::decode_event(*t, p).expect("event decodes")) - .collect() - } - - /// Wait (5 s) for the next event of `E` that `pick` accepts. - pub async fn wait_event(&mut self, pick: impl Fn(&E) -> bool) -> E { - let deadline = tokio::time::Instant::now() + Duration::from_secs(5); - loop { - let left = deadline.saturating_duration_since(tokio::time::Instant::now()); - if let ServerMessage::Event { type_id, payload } = - self.next(left).await.expect("event within 5 s") - { - if let Some(event) = E::decode_event(type_id, &payload).expect("event decodes") { - if pick(&event) { - return event; - } - } - } - } - } - - /// Read messages until `pred` holds (10 s). - pub async fn until(&mut self, what: &str, pred: impl Fn(&Client) -> bool) { - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); - while !pred(self) { - assert!(tokio::time::Instant::now() < deadline, "timed out: {what}"); - if let Some(ServerMessage::Error { code, message }) = - self.next(Duration::from_millis(100)).await - { - panic!("{what}: error {code}: {message}"); - } - } - } - - /// Entities of `kind` currently mirrored. - pub fn count(&self, kind: u16) -> usize { - self.mirror - .entities - .values() - .filter(|e| e.kind == kind) - .count() - } -} - -/// Minimal HTTP/1.1 client: `(status, JSON body or Null)`. -pub async fn http(addr: SocketAddr, method: &str, path: &str, body: Option) -> (u16, Value) { - let mut stream = TcpStream::connect(addr).await.unwrap(); - let body = body.map(|b| b.to_string()).unwrap_or_default(); - let request = format!( - "{method} {path} HTTP/1.1\r\nHost: test\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", - body.len() - ); - stream.write_all(request.as_bytes()).await.unwrap(); - let mut response = String::new(); - stream.read_to_string(&mut response).await.unwrap(); - let status = response[9..12].parse().unwrap(); - let json = response - .split_once("\r\n\r\n") - .and_then(|(_, b)| serde_json::from_str(b).ok()) - .unwrap_or(Value::Null); - (status, json) -} - -/// Poll `check` until true (10 s). -pub async fn eventually(what: &str, mut check: F) -where - F: FnMut() -> Fut, - Fut: std::future::Future, -{ - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); - while !check().await { - assert!(tokio::time::Instant::now() < deadline, "timed out: {what}"); - tokio::time::sleep(Duration::from_millis(20)).await; - } -} From 6eb5c28d61dda6938b77102088f731aba6992511 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 21 Oct 2026 00:42:38 -0500 Subject: [PATCH 06/21] feat(game)!: Mob Pit hoods, boroughs, city; delete Boss Ripple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rooms group into hoods (3 × 3) and boroughs (3 × 3 hoods) under one city. Each tier is an aggregate cell on celld (new crate mobpit_tiers): every hood has a seeded boss room; a boss kill is a zone fact into the hood (only from that room, only into its hood) that raises the hood's tier and schedules the boss's return (ctx.schedule). The worker's Queue consumer runs the saga that ripples hood events into the borough (bosses down, weather) and borough events into the city (banner): idempotent commands keyed by event id, stale steps ignored by stream sequence. Rooms read their tiers off the tick (CelldZone::refresh) and tell their players; a hood's tier makes its rooms tougher, a fallen boss stays down until its hood brings it back. Vite and SvelteKit show the borough's weather (Boss Ripple's fog, clear colour, light) with fog and darkness thickening toward boss rooms (boss_distance from the shared room generator), boss rooms and hood / borough borders on the minimap (/dev/mobpit/tiers), and the tiers in the HUD. Boss Ripple is deleted with the native multi-host plumbing only it used (cluster.rs, test support, run.sh cluster runner, the worker's zone lease and zone state cells). Tests: mobpit_tiers (hood → borough → city on the reference host, respawn schedule, stale steps), rooms (a party fells a boss, the fact, the boss stays down), mobpit-bosses-headless (the saga end to end on celld), SvelteKit weather and darkness. Refs [[tasks/gr-p44-mobpit-world]] --- examples/game/README.md | 100 ++- examples/game/cells/Cargo.toml | 6 +- examples/game/cells/src/lib.rs | 219 +++-- examples/game/cells/wrangler.jsonc | 16 +- examples/game/client/index.html | 1 - .../client/src/generated/mobpit/constants.ts | 6 +- .../client/src/generated/mobpit/events.ts | 96 +++ .../game/client/src/generated/mobpit/pures.ts | 4 + .../client/src/generated/mobpit/surface.json | 178 +++++ examples/game/client/src/mobpit/client.ts | 23 +- examples/game/client/src/mobpit/main.ts | 102 ++- .../tests/mobpit-bosses-headless.test.mjs | 107 +++ .../client/tests/prediction-wasm.test.mjs | 4 +- examples/game/client/vite.config.ts | 2 +- examples/game/run.sh | 64 +- examples/game/server/Cargo.lock | 14 + examples/game/server/Cargo.toml | 4 +- examples/game/server/crawler/src/lib.rs | 4 +- examples/game/server/rooms/Cargo.toml | 2 + examples/game/server/rooms/src/celld.rs | 68 +- examples/game/server/rooms/src/zone.rs | 188 ++++- examples/game/server/rooms/tests/rooms.rs | 120 +++ examples/game/server/sim/src/lib.rs | 2 - examples/game/server/sim/src/mobpit.rs | 154 +++- examples/game/server/src/lib.rs | 2 - examples/game/server/src/main.rs | 5 - examples/game/server/src/mobpit/routes.rs | 57 ++ examples/game/server/src/mobpit/store.rs | 6 + examples/game/server/src/predict_trace.rs | 4 +- examples/game/server/tiers/Cargo.toml | 23 + examples/game/server/tiers/src/lib.rs | 747 ++++++++++++++++++ examples/game/server/tiers/tests/tiers.rs | 208 +++++ .../src/lib/generated/mobpit/constants.ts | 6 +- .../src/lib/generated/mobpit/events.ts | 96 +++ .../src/lib/generated/mobpit/pures.ts | 4 + .../src/lib/generated/mobpit/surface.json | 178 +++++ examples/game/sveltekit/src/lib/pit/loop.ts | 19 +- .../sveltekit/src/routes/pit/+page.svelte | 22 +- examples/game/sveltekit/tests/pit.test.mjs | 6 + 39 files changed, 2640 insertions(+), 227 deletions(-) create mode 100644 examples/game/client/tests/mobpit-bosses-headless.test.mjs create mode 100644 examples/game/server/tiers/Cargo.toml create mode 100644 examples/game/server/tiers/src/lib.rs create mode 100644 examples/game/server/tiers/tests/tiers.rs diff --git a/examples/game/README.md b/examples/game/README.md index f1991e8b..ebd76e40 100644 --- a/examples/game/README.md +++ b/examples/game/README.md @@ -8,12 +8,11 @@ guests and lets any page change the network simulation. | Demo | Run | Mechanic it demonstrates | Durable state | |---|---|---|---| -| **Mob Pit** | `./run.sh mobpit` (`--fleet` for a local multi-node celld) | A near-infinite seeded grid of rooms joined by doorways, **each room a celld Durable Object** (celld places, forwards, hibernates and recovers rooms; the debug overlay shows the node); authoritative melee combat vs. telegraphing mobs; the **rooms next door** watched as passive observers (their mobs are drawn; a watcher never wakes a sleeping room or keeps one awake); a **network panel** (latency / jitter / loss applied by every room object on celld, client prediction of the Rust step as WASM with reconciliation, a server ghost, an interest ring: what is outside is not replicated); kills are **non-blocking durable facts** settled exactly once in per-player cells; rooms you leave go dormant then asleep (live minimap from the zone status read model) and come back with their mobs | celld: `pit..` rooms (snapshots), `crawler/{player}`, `presence/{player}`, `zone-activity/{room}`; crawler list and room status = read models projected from the cells' events | +| **Mob Pit** | `./run.sh mobpit` (`--fleet` for a local multi-node celld) | A near-infinite seeded grid of rooms joined by doorways, **each room a celld Durable Object** (celld places, forwards, hibernates and recovers rooms; the debug overlay shows the node); authoritative melee combat vs. telegraphing mobs; **hoods, boroughs and a city** above the rooms (each tier a celld cell: a seeded boss room per hood, a boss kill raises the hood's tier — tougher rooms — and a saga ripples it up; the borough's weather and darkness lift as its bosses fall, fog thickens toward boss rooms); the **rooms next door** watched as passive observers (their mobs are drawn; a watcher never wakes a sleeping room or keeps one awake); a **network panel** (latency / jitter / loss applied by every room object on celld, client prediction of the Rust step as WASM with reconciliation, a server ghost, an interest ring: what is outside is not replicated); kills are **non-blocking durable facts** settled exactly once in per-player cells; rooms you leave go dormant then asleep (live minimap from the zone status read model) and come back with their mobs | celld: `pit..` rooms (snapshots), `crawler/{player}`, `presence/{player}`, `zone-activity/{room}`; crawler list and room status = read models projected from the cells' events | | **Leaderboard** | `./run.sh leaderboard` | Same setup as Mob Pit: kills/XP sorted-set boards (in-memory or Redis) fed from the projected crawler list with idempotent `max` updates, your rank + neighbours, rebuild from the cells' event history | celld (the Mob Pit cells); boards are a projection (memory or Redis) | | **Shooting Gallery** | `./run.sh gallery` | Lag compensation: hitscan shots resolved with `ctx.rewind().hit_test_shot` at the shooter's view time (`client_tick` + interpolation delay in the payload), `max_rewind` clamp, per-shot compensation on/off, predicted vs confirmed hits | none (stateless: score is per session) | | **The Freeze** | `./run.sh freeze` | World-version barrier (`WorldCoordinator::rollout`) across **two server processes** as an in-world "admin update": freeze → apply → resume with change notes, inputs dropped while frozen, Hard reload vs safe-room gating (`StalePolicy::SafeRoom`, `Zone::is_gate`) of old clients, aborted rollout (`freeze_ready` → `NotAcked`), facts/events stamped with the content version | celld: `gem-pouch/{player}`, `content-log/{zone}` | | **Mob Pit · SvelteKit** | `REDIS_URL=… ./run.sh sveltekit` | The same Mob Pit server behind a SvelteKit app: Auth.js session sign-in, admission tickets minted from the session (`createGameTicketHandler`), the game client and WASM step generated by the Vite plugin (`games`), a runes HUD (`useGame`) next to a plain canvas loop, the settled kills as a business-side read model (Crawler domain events projected by `mobpit-service` into SQL, read through generated `@load @live` GraphQL), and the zone server's Redis XP board through GraphQL board roots, live across processes over the bus. See [SvelteKit](#sveltekit) | celld (the Mob Pit cells) + `mobpit-service`'s SQL read model + the Redis boards | -| **Boss Ripple** | `./run.sh ripple` | Zone hierarchy realm › city › borough › hood (`DottedTopology`), per-tier tick rates (2/5/10/30 Hz), boss defeats propagating up as tier-up events (each hop on another host), parent broadcasts reaching players through observer connections | celld: `zone-lease/{zone}`, `zone-state/{zone}` (hibernation) | `./run.sh smoke` runs every demo's smoke tests (and checks the generated clients are current, and drives the SvelteKit demo headlessly); `./run.sh gen` regenerates the clients; `./run.sh @@ -28,8 +27,7 @@ for the kinds (`mobpit::zone::PlayerView`, `mobpit::zone::MobView`, …), command inputs they emit as facts (`mobpit::crawler::RecordKillInput`). The movement that clients predict is declared once with `#[predict]` in the `game_sim` crate (`server/sim/`, no server dependencies): -`arena_step` (The Freeze), `pit_step` (Mob Pit) and `hood_step` (Boss -Ripple); the zones call those functions in `on_tick`. Each demo module +`arena_step` (The Freeze) and `pit_step` (Mob Pit); the zones call those functions in `on_tick`. Each demo module exports `game_surface()`; `run.sh gen` runs `distributed game-manifest` on it, `distributed game-client` writes `client/src/generated//` (checked in, verified by `run.sh smoke`) and `distributed game-wasm` @@ -44,9 +42,9 @@ No simulation or content code is written twice. Movement is the compiled step; Mob Pit's rooms (biome, danger, mobs) come from the compiled `pit_room` (`createMobpitPures`); static data the pages draw is a generated constant from the Rust values (`PIT_WORLD` — room -size, doorways, pillars, capsule radius —, `ARENA_FLOOR` for The Freeze and -Boss Ripple, `FREEZE_WORLD`, `PIT_RULES` tier radii, `RIPPLE_WORLD` zone -hierarchy, `GALLERY_COURSE` rails); The Freeze's content packs are a Rust +size, doorways, pillars, capsule radius —, `WEATHER` names, `ARENA_FLOOR` +for The Freeze, `FREEZE_WORLD`, `PIT_RULES` tier radii, `GALLERY_COURSE` +rails); The Freeze's content packs are a Rust record served in its payload encoding and decoded by the generated `decodePack`. What TypeScript still mirrors is small and listed under [Remaining client-side mirrors](#remaining-client-side-mirrors). The @@ -142,34 +140,6 @@ their stamp. Display names live on the primary host: a player joining Ember Fields directly (`&zone=freeze-ember`) shows as a guest there, names carry over portals. -## Boss Ripple - -```sh -examples/game/run.sh ripple -# → http://localhost:5173/ripple/ (?hood=realm.city.borough-2.hood-3 optional) -``` - -`realm` › `realm.city` › `realm.city.borough-{1,2}` › -`….hood-{1,2,3}`. Placement alternates by depth over the two hosts (hoods -and the city on A, boroughs and the realm on B), so every hop is a -`/admin/child-event` call between processes. Tick rates per tier come from -`GameServer::with_host_config`: realm 2 Hz (keeps an idle background -clock), city 5, borough 10, hood 30. Upper tiers refuse players; they are -observed. - -1. A hood's boss falls (E near it, or "Smite") → `boss_defeated` to the root. -2. The borough tallies its hoods; all down → `borough_cleared` (unlocked). -3. The city's weather follows the remaining bosses (storm → rain → - clearing → clear) and is broadcast to its observers — every player - observes the city, so the sky over their hood changes; both boroughs - cleared → `city_liberated`. -4. The realm counts everything and broadcasts a banner. - -Bosses respawn after 60 s (`boss_respawned` re-locks the borough). Every -tier's state is its hibernation snapshot. The page shows the tree with -each tier's host, rate and clock, animates each hop, and lets you click a -hood to fight there. - ## SvelteKit ```sh @@ -346,13 +316,15 @@ examples/game/ cells/ crate `game-demo-cells` (own workspace, wasm32, worker-build): Mob Pit's rooms (`PitRoom`, one Durable Object per room) and the demos' celld cells — Crawler + presence + zone activity + dev settings - (`ZoneDevObject`: the rooms' NetSim) (Mob Pit), gem - pouch + content log (The Freeze), zone lease + zone state - (multi-host demos), and the `game-demo-outbox` Queue consumer (relay) + (`ZoneDevObject`: the rooms' NetSim) + hood / borough / city tiers + (Mob Pit), gem pouch + content log (The Freeze), and the + `game-demo-outbox` Queue consumer (relay + the tier saga) server/ Rust crate `game_demos`, its own cargo workspace (with sim/, crawler/, rooms/, service/); also the `distributed build|dev` project crawler/ crate `mobpit_crawler`: Crawler aggregate, RecordKill (XP rules), `CrawlerState` domain event (shared by the zone and the service) + tiers/ crate `mobpit_tiers`: Hood / Borough / City aggregates, the boss + fact, the saga steps (tests/tiers.rs on the reference host) rooms/ crate `mobpit_rooms` (wasm32-portable): the Mob Pit room zone (combat, doorways, hibernation) and its `CelldZone` hooks service/ crate `mobpit_service` (bin `mobpit-service`): player_stats and @@ -361,7 +333,7 @@ examples/game/ @live (Bearer only), tests/projection.rs (cells → bus → table → live push, board push over bus notices, no mutations, 401) sim/ crate `game_sim`: the `#[predict]` steps (arena.rs movement + floor, - mobpit.rs (+ `#[game_pure]` pit_room, doorways), ripple.rs inputs + steps, + mobpit.rs (+ `#[game_pure]` pit_room, doorways), grid.rs seeded noise, biomes and props); compiled natively for the server and to wasm32 for the browser src/main.rs `game-demos ` subcommands (+ hidden `predict-trace`) @@ -371,15 +343,13 @@ examples/game/ relay.rs (authenticated /internal/celld-queue/relay → SqliteBus), zone_status.rs (zone lease/snapshot/activity status read model) src/devcelld.rs `celld dev` harness for tests (own project, port, process tree) - src/cluster.rs multi-host demos: --host-id/--peer, directory, ownership, snapshots src/mobpit/ Mob Pit services (the rooms run on celld): dev room tickets, relay, crawler directory and room status projections, leaderboards src/gallery/ Shooting Gallery zone (targets, rewind hit tests) src/freeze/ The Freeze zone, content packs, two-host world + admin routes - src/ripple/ Boss Ripple tiers and placement tests/ mobpit_smoke.rs (facts, rooms on celld, celld crash, boards, presence), gallery_smoke.rs, freeze_smoke.rs (binary content packs), - ripple_smoke.rs (+ support/ for two in-process hosts) + (Boss Ripple and the multi-host plumbing are gone: Mob Pit's tiers are cells) sveltekit/ SvelteKit app over the Mob Pit server + mobpit-service (see SvelteKit above) distributed.config.js game surface (`games`) + GraphQL client (`clients`) for the Vite plugin distributed.clients.json the `$distributed` client of `mobpit_service` (lifecycle-generated) @@ -404,13 +374,12 @@ examples/game/ src/leaderboard/ main.ts (DOM page over the observer connection), style.css src/gallery/ client.ts (shots, adaptive delay), main.ts src/freeze/ client.ts (content packs, live patch / reload, admin API), main.ts - src/ripple/ client.ts (headless), main.ts (Babylon) tests/ prediction-wasm (native ↔ WASM exact match of steps + pure functions, cost), mobpit-headless, mobpit-network-headless (NetSim on celld rooms, exact prediction, interest), mobpit-restarts-headless, leaderboard-headless, mobpit-neighbours-headless (passive watchers), gallery-headless, freeze-headless, - ripple-headless (+ helpers/demo-server.mjs, helpers/mobpit.mjs, - helpers/cluster.mjs for multi-host demos, helpers/celld.mjs for + mobpit-bosses-headless (hood → borough → city) (+ helpers/demo-server.mjs, + helpers/mobpit.mjs, helpers/celld.mjs for a per-test `celld dev`) ``` @@ -500,6 +469,39 @@ a toast. The minimap (bottom right) shows every known room live from the each room's lifecycle facts into its `zone-activity/{room}` cell, whose events reach the services through the outbox Queue relay. +**Hoods, boroughs, the city.** Rooms group into hoods (3 × 3 rooms) and +hoods into boroughs (3 × 3 hoods, 9 × 9 rooms) under one city +(`game_sim::mobpit::{hood_of, borough_of}`; minimap: purple hood borders, +yellow borough borders). Every hood has one seeded **boss room** +(`boss_room`, shared Rust: rooms, cells and the page agree) whose boss +(tier 4, a red tower) hits hard. Each tier is an **aggregate cell** on +celld (`server/tiers`, `mobpit_tiers`), with its own schedule: + +- `hood/{hx.hy}`: a boss kill is a zone fact of the boss room + (`hood.boss_defeated`, keyed by the boss's life, only from that room's + zone principal and only into its own hood): the boss is down, the hood's + **tier** rises, and the hood schedules the boss's return + (`ctx.schedule`, 90 s). The slayer's Crawler cell credits 200 XP (decided + by the cell). +- The hood's `hood.changed` domain event leaves through its outbox; the + worker's Queue consumer runs the **saga**: `borough.hood_changed` into + `borough/{bx.by}` (bosses down → its **weather**: storm, rain, clearing, + clear), whose `borough.changed` becomes `city.borough_changed` in + `city/city` (bosses down, boroughs liberated, the **banner**). Each step + is an idempotent command (id from the event id) carrying the source's + stream sequence, so a repeated or late step changes nothing. +- Down again: every room reads its hood and borough every 2 s and the city + every 10 s (`CelldZone::refresh`, off the tick) and tells its players + (`world`); a hood's tier makes its rooms' mobs tougher (+25 % HP and + +20 % damage per tier), and a fallen boss stays down until its hood + says it is back. +- The page draws the borough's weather (Boss Ripple's exp2 fog, clear + colour and light; lightning in a storm) and, within it, fog and darkness + thicken the closer the room is to a boss room (`boss_distance` of the + generated layout). The minimap marks boss rooms (red; grey while down) + with their hood's tier (`GET /dev/mobpit/tiers?room=` reads the cells + around); the HUD shows hood, borough, weather and the city banner. + **One kill, end to end:** 1. `rooms/src/zone.rs`, tick N: the mob's HP reaches 0. The zone builds @@ -563,7 +565,7 @@ fleet test) kills the owner node of a room with 200 facts in flight and counts exactly 200 in the cell. **celld.** Every demo with durable state runs on celld: -`run.sh mobpit|leaderboard|freeze|ripple|sveltekit|smoke` need the celld +`run.sh mobpit|leaderboard|freeze|sveltekit|smoke` need the celld version pinned in the repository's `.celld-version` (`CELLD_BIN`, default `celld` on PATH; `scripts/celld.sh check` verifies it and prints the install command) and `worker-build` (`cargo install worker-build --locked`) for @@ -649,12 +651,6 @@ room object's debug JSON (node, isolate, activation, wakes, tick timing). `startDemo('')` from `tests/helpers/demo-server.mjs`. 5. Register the demo in `run.sh` (`DEMOS=(…)`) and in the table above. -A **multi-host** demo also implements `cluster::ClusterDemo` (`directory` -over the hosts, `cluster_server`, optional dev routes), uses -`DemoCommand::X(ClusterArgs)` → `run_cluster::`, is listed in -`CLUSTER_DEMOS` in `run.sh`, and tests with `tests/support` (Rust) / -`startCluster` (Node). - ## Security notes (why this is dev only) - `--dev` (or `DG_DEMO_DEV=1`) mounts `/dev/*`; without it only `/zones` and diff --git a/examples/game/cells/Cargo.toml b/examples/game/cells/Cargo.toml index 5bc8471c..67a30d97 100644 --- a/examples/game/cells/Cargo.toml +++ b/examples/game/cells/Cargo.toml @@ -3,8 +3,8 @@ # their zone-activity cells, Mob Pit's per-player Crawler aggregate and # per-player presence # (with the outbox Queue consumer that relays Crawler domain events to the -# zone server), and the zone lease and hibernation-state cells every -# multi-host demo uses. +# zone server and runs the world tier saga), and the hood / borough / city +# tier cells. # Own workspace (cdylib for wasm32-unknown-unknown); built with # `worker-build --release` by `examples/game/run.sh`. [workspace] @@ -26,6 +26,8 @@ console_error_panic_hook = "0.1" distributed = { path = "../../..", default-features = false, features = ["workers-rs"] } distributed_game = { path = "../../../distributed_game", default-features = false, features = ["cell", "workers-rs", "celld-zone"] } mobpit_crawler = { path = "../server/crawler" } +# The world tiers (hood, borough, city cells) and their saga steps. +mobpit_tiers = { path = "../server/tiers" } # Mob Pit's rooms, each one a Durable Object (`PitRoom`). mobpit_rooms = { path = "../server/rooms", features = ["celld"] } serde = { version = "1", features = ["derive"] } diff --git a/examples/game/cells/src/lib.rs b/examples/game/cells/src/lib.rs index fdc18ead..8976b71e 100644 --- a/examples/game/cells/src/lib.rs +++ b/examples/game/cells/src/lib.rs @@ -12,10 +12,14 @@ //! (active / dormant / hibernated); its `zone.activity_changed` events //! leave through the outbox Queue for the zone status read model. //! +//! - `hood/{hx.hy}`, `borough/{bx.by}`, `city/city` (`HoodCell`, +//! `BoroughCell`, `CityCell`): Mob Pit's world tiers (`mobpit_tiers`). A +//! boss room's zone fact raises its hood; the Queue consumer below runs +//! the saga that ripples hood events into the borough and borough events +//! into the city (idempotent commands, ids derived from the event ids). +//! //! The Freeze's cells are fed by its zone server's -//! `distributed_game::celld::CelldFactSink` over the internal HTTP path; -//! every native multi-host demo keeps its zone ownership and hibernation -//! snapshots here. +//! `distributed_game::celld::CelldFactSink` over the internal HTTP path. //! //! - `crawler/{principal}` (`CrawlerCell`): the `mobpit_crawler::Crawler` //! aggregate with its `#[command]` `crawler.record_kill` (XP decided here @@ -31,18 +35,12 @@ //! version; the zone reports no amount). //! - `content-log/{zone}` (`ContentLogCell`): The Freeze's //! `freeze.content_applied` zone facts (every content switch of a zone). -//! - `zone-lease/{zone}` (`ZoneLeaseCell`): zone ownership -//! (`distributed_game::world::zone_lease`, `CellZoneOwnership`); its -//! alarm declares `zone.lease_expired`. -//! - `zone-state/{zone}` (`ZoneStateCell`): hibernation snapshots -//! (`distributed_game::world::zone_state`, `CellZoneStateStore`); large -//! ones in the `ZONE_SNAPSHOTS` R2 bucket. //! //! Every route requires `DISTRIBUTED_INTERNAL_SECRET`. Mob Pit commands also //! require the trusted fact session (principal + `FactStamp` headers, //! rebuilt with `fact_session_from_headers`), so nothing but a zone server's -//! fact worker can run them; zone lease and state commands require the -//! `zone-host` role. +//! fact worker can run them; tier saga steps require the worker's own saga +//! session (`mobpit_tiers::SAGA_PRINCIPAL`). //! //! The Queue consumer relays each outbox message to `CELLD_QUEUE_RELAY_URL` //! (the zone server's authenticated `/internal/celld-queue/relay`), which @@ -66,12 +64,14 @@ use distributed_game::lifecycle::{ Lifecycle, CONNECTION_LOST, PLAYER_DISCONNECTED, PLAYER_JOINED, PLAYER_LEFT, PLAYER_RECONNECTED, }; use distributed_game::world::zone_activity::ZONE_ACTIVITY_KIND; -use distributed_game::world::zone_lease::{mount_zone_lease, ZoneLease}; -use distributed_game::world::zone_state::{celld_worker, mount_zone_state, ZoneState}; use distributed_game::FactStamp; use mobpit_crawler::{ Crawler, MobKilledArgs, RecordKill, CRAWLER_KIND, MOB_KILLED_EVENT, PRESENCE_KIND, }; +use mobpit_tiers::{ + mount_borough, mount_city, mount_hood, Borough, City, Hood, BOROUGH_KIND, CITY_KIND, HOOD_KIND, + SAGA_PRINCIPAL, +}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use worker::*; @@ -458,18 +458,6 @@ fn fact_session(header: &dyn Fn(&str) -> Option) -> Option { fact_session_from_headers(header) } -/// Zone lease cells: the zone server's identity and role (the handlers -/// require `zone-host`). -fn zone_host_session(header: &dyn Fn(&str) -> Option) -> Option { - let mut session = Session::new(); - for key in [distributed::USER_ID_KEY, distributed::microsvc::ROLE_KEY] { - if let Some(value) = header(key) { - session.set(key, value); - } - } - Some(session) -} - type Sessions = fn(&dyn Fn(&str) -> Option) -> Option; async fn dispatch( @@ -657,73 +645,111 @@ where Response::ok("ok") } +// ---------------------------------------------------------------- Rooms + +distributed_game::celld_zone_object!(PitRoom, mobpit_rooms::MobPit); + +// ---------------------------------------------------------------- Tiers + +/// Tier saga steps run only for the worker's own session. +fn saga_session(header: &dyn Fn(&str) -> Option) -> Option { + let principal = header(distributed::USER_ID_KEY)?; + (principal == SAGA_PRINCIPAL).then(|| { + let mut session = Session::new(); + session.set(distributed::USER_ID_KEY, principal); + session + }) +} + +async fn read_hood(cell: &AggregateCell) -> std::result::Result { + let hood = cell.load().await.map_err(|error| error.to_string())?; + Ok(json!({ "state": hood.map(|h| h.state()) })) +} + +async fn read_borough(cell: &AggregateCell) -> std::result::Result { + let borough = cell.load().await.map_err(|error| error.to_string())?; + Ok(json!({ "state": borough.map(|b| b.state()) })) +} + +async fn read_city(cell: &AggregateCell) -> std::result::Result { + let city = cell.load().await.map_err(|error| error.to_string())?; + Ok(json!({ "state": city.map(|c| c.state()) })) +} + +/// A hood: its boss rooms' facts and its own respawn schedule. #[durable_object] -pub struct ZoneLeaseCell { - cell: AggregateCell, +pub struct HoodCell { + cell: AggregateCell, env: Env, } -impl DurableObject for ZoneLeaseCell { +impl DurableObject for HoodCell { fn new(state: State, env: Env) -> Self { console_error_panic_hook::set_once(); - // Renewals are internal events: snapshots keep loads short. - let cell = mount_zone_lease( - AggregateCell::::from_state_with_snapshots(state, 50) - .expect("zone lease cell identity"), + let cell = mount_hood( + AggregateCell::::from_state_with_snapshots(state, 50) + .expect("hood cell identity"), ) .with_celld_outbox(outbox(&env)); Self { cell, env } } async fn fetch(&self, req: Request) -> Result { - route(&self.env, &self.cell, req, read_lease, zone_host_session).await + route(&self.env, &self.cell, req, read_hood, fact_session).await } async fn alarm(&self) -> Result { - let wake = self - .cell - .alarm(&self.env) - .await - .map_err(|error| Error::RustError(error.to_string()))?; - for failed in wake.schedules.failed.iter().chain(&wake.schedules.retried) { - worker::console_error!("lease alarm failed: {} {}", failed.code, failed.message); - } - Response::ok("ok") + alarm(&self.env, &self.cell).await } } -async fn read_lease(cell: &AggregateCell) -> std::result::Result { - let lease = cell.load().await.map_err(|error| error.to_string())?; - Ok(json!({ - "holder": lease.as_ref().and_then(|l| l.holder().map(str::to_string)), - "token": lease.as_ref().map(|l| l.token()), - })) +/// A borough: its hoods as the saga reports them. +#[durable_object] +pub struct BoroughCell { + cell: AggregateCell, + env: Env, } +impl DurableObject for BoroughCell { + fn new(state: State, env: Env) -> Self { + console_error_panic_hook::set_once(); + let cell = mount_borough( + AggregateCell::::from_state_with_snapshots(state, 50) + .expect("borough cell identity"), + ) + .with_celld_outbox(outbox(&env)); + Self { cell, env } + } + + async fn fetch(&self, req: Request) -> Result { + route(&self.env, &self.cell, req, read_borough, saga_session).await + } + + async fn alarm(&self) -> Result { + alarm(&self.env, &self.cell).await + } +} + +/// The city: its boroughs as the saga reports them. #[durable_object] -pub struct ZoneStateCell { - cell: AggregateCell, +pub struct CityCell { + cell: AggregateCell, env: Env, } -impl DurableObject for ZoneStateCell { +impl DurableObject for CityCell { fn new(state: State, env: Env) -> Self { console_error_panic_hook::set_once(); - // Every save replaces the value: snapshot each commit. - let cell = mount_zone_state( - AggregateCell::::from_state_with_snapshots(state, 1) - .expect("zone state cell identity"), + let cell = mount_city( + AggregateCell::::from_state_with_snapshots(state, 50) + .expect("city cell identity"), ) .with_celld_outbox(outbox(&env)); Self { cell, env } } async fn fetch(&self, req: Request) -> Result { - if let Err(error) = authenticate(&req, &self.env) { - return error_response(error); - } - let bucket = celld_worker::bucket(&self.env, "ZONE_SNAPSHOTS")?; - celld_worker::fetch(&self.cell, &bucket, req).await + route(&self.env, &self.cell, req, read_city, saga_session).await } async fn alarm(&self) -> Result { @@ -731,9 +757,64 @@ impl DurableObject for ZoneStateCell { } } -// ---------------------------------------------------------------- Rooms - -distributed_game::celld_zone_object!(PitRoom, mobpit_rooms::MobPit); +/// The tier saga's next step for an outbox message (hood → borough → +/// city), dispatched object to object as an idempotent command whose id +/// derives from the message id. +async fn saga(env: &Env, envelope: &CelldQueueEnvelope) -> std::result::Result<(), String> { + let message = &envelope.message; + let Some(step) = mobpit_tiers::saga_step(message.name(), message.payload()) else { + return Ok(()); + }; + let id = message.id().ok_or("tier event without an id")?; + let secret = env + .secret(CELL_INTERNAL_SECRET_ENV) + .map(|v| v.to_string()) + .or_else(|_| env.var(CELL_INTERNAL_SECRET_ENV).map(|v| v.to_string())) + .map_err(|_| "internal secret is required".to_string())?; + let headers = Headers::new(); + let set = |name: &str, value: &str| headers.set(name, value).map_err(|e| e.to_string()); + set(CELL_INTERNAL_SECRET_HEADER, &secret)?; + set(CELL_SERVICE_ID_HEADER, "mobpit-tiers")?; + set(CELL_PRINCIPAL_PARTITION_HEADER, SAGA_PRINCIPAL)?; + set(distributed::USER_ID_KEY, SAGA_PRINCIPAL)?; + set("content-type", "application/json")?; + let body = json!({ + "commandId": distributed_game::cell::command_id_for("mobpit-tiers", id), + "input": step.input, + }); + let mut init = RequestInit::new(); + init.with_method(Method::Post) + .with_headers(headers) + .with_body(Some(body.to_string().into())); + let url = format!("https://cell/{}/{}/{}", step.kind, step.shard, step.command); + let request = Request::new_with_init(&url, &init).map_err(|e| e.to_string())?; + let binding = match step.kind { + BOROUGH_KIND => "BOROUGH", + _ => "CITY", + }; + let namespace = env.durable_object(binding).map_err(|e| e.to_string())?; + let stub = namespace + .id_from_name(&step.shard) + .and_then(|id| id.get_stub()) + .map_err(|e| e.to_string())?; + let mut response = stub + .fetch_with_request(request) + .await + .map_err(|e| e.to_string())?; + let status = response.status_code(); + if (200..300).contains(&status) { + Ok(()) + } else { + let body = response.text().await.unwrap_or_default(); + // A refused step (bad input) never succeeds on retry: drop it. + if status == 400 || status == 403 || status == 422 { + worker::console_error!("tier saga step {} refused: {status} {body}", step.command); + Ok(()) + } else { + Err(format!("{} → HTTP {status}: {body}", step.command)) + } + } +} /// The rooms' dev settings (`zone-dev/{shard}`: the network simulation the /// rooms apply, written by the services' `/dev/netsim`). @@ -802,10 +883,11 @@ async fn main(req: Request, env: Env, _ctx: Context) -> Result { Some(PRESENCE_KIND) => "PRESENCE", Some("gem-pouch") => "GEM_POUCH", Some("content-log") => "CONTENT_LOG", - Some("zone-lease") => "ZONE_LEASE", - Some("zone-state") => "ZONE_STATE", Some(ZONE_ACTIVITY_KIND) => "ZONE_ACTIVITY", Some(distributed_game::netsim::ZONE_DEV_KIND) => "ZONE_DEV", + Some(HOOD_KIND) => "HOOD", + Some(BOROUGH_KIND) => "BOROUGH", + Some(CITY_KIND) => "CITY", _ => return json_status(json!({ "error": "unknown cell kind" }), 404), }; let Some(shard) = parts.get(1) else { @@ -833,6 +915,13 @@ pub async fn queue(batch: MessageBatch, env: Env, _ctx: Cont } }; for delivery in batch.messages()? { + // The tier saga first: a failed step retries the whole delivery + // (each step is idempotent). + if let Err(error) = saga(&env, delivery.body()).await { + worker::console_error!("tier saga for {} deferred: {error}", delivery.id()); + delivery.retry(); + continue; + } match relay.relay(delivery.body().clone()).await { Ok(()) => delivery.ack(), Err(error) => { diff --git a/examples/game/cells/wrangler.jsonc b/examples/game/cells/wrangler.jsonc index d15a8c40..054132a1 100644 --- a/examples/game/cells/wrangler.jsonc +++ b/examples/game/cells/wrangler.jsonc @@ -11,25 +11,25 @@ "bindings": [ { "name": "CRAWLER", "class_name": "CrawlerCell" }, { "name": "PRESENCE", "class_name": "PresenceCell" }, - { "name": "ZONE_LEASE", "class_name": "ZoneLeaseCell" }, - { "name": "ZONE_STATE", "class_name": "ZoneStateCell" }, { "name": "GEM_POUCH", "class_name": "GemPouchCell" }, { "name": "CONTENT_LOG", "class_name": "ContentLogCell" }, { "name": "PIT_ROOM", "class_name": "PitRoom" }, { "name": "ZONE_ACTIVITY", "class_name": "ZoneActivityObject" }, - { "name": "ZONE_DEV", "class_name": "ZoneDevObject" } + { "name": "ZONE_DEV", "class_name": "ZoneDevObject" }, + { "name": "HOOD", "class_name": "HoodCell" }, + { "name": "BOROUGH", "class_name": "BoroughCell" }, + { "name": "CITY", "class_name": "CityCell" } ] }, + // v2's ZoneLeaseCell / ZoneStateCell (the deleted multi-host demos) are + // no longer exported; celld has no `deleted_classes` migration key. "migrations": [ { "tag": "v1", "new_sqlite_classes": ["CrawlerCell", "PresenceCell"] }, { "tag": "v2", "new_sqlite_classes": ["ZoneLeaseCell", "ZoneStateCell"] }, { "tag": "v3", "new_sqlite_classes": ["GemPouchCell", "ContentLogCell"] }, { "tag": "v4", "new_sqlite_classes": ["PitRoom", "ZoneActivityObject"] }, - { "tag": "v5", "new_sqlite_classes": ["ZoneDevObject"] } - ], - // Hibernation snapshots above the inline limit (celld's object storage). - "r2_buckets": [ - { "binding": "ZONE_SNAPSHOTS", "bucket_name": "zone-snapshots" } + { "tag": "v5", "new_sqlite_classes": ["ZoneDevObject"] }, + { "tag": "v6", "new_sqlite_classes": ["HoodCell", "BoroughCell", "CityCell"] } ], "queues": { "producers": [ diff --git a/examples/game/client/index.html b/examples/game/client/index.html index 0be41463..25e94836 100644 --- a/examples/game/client/index.html +++ b/examples/game/client/index.html @@ -15,7 +15,6 @@

distributed_game demos

  • Leaderboard — kills/XP sorted-set boards projected from the cells, pushed live, rebuilt from events (runs with Mob Pit).
  • Shooting Gallery — lag compensation: hitscan shots rewound to the shooter's view time, max-rewind clamp, compensation on/off.
  • The Freeze — world-version barrier across two server processes as a story beat: hard reload, safe-room gating of old clients, aborted rollout.
  • -
  • Boss Ripple — realm › city › borough › hood, per-tier tick rates, boss defeats rippling up as tier-up events across hosts.
  • diff --git a/examples/game/client/src/generated/mobpit/constants.ts b/examples/game/client/src/generated/mobpit/constants.ts index b1acf0eb..0e3c20f8 100644 --- a/examples/game/client/src/generated/mobpit/constants.ts +++ b/examples/game/client/src/generated/mobpit/constants.ts @@ -8,7 +8,8 @@ export const PIT_RULES: PitRules = { tiers: [ { tier: 1, radius: 0.5, tele: 1.7999999523162842 }, { tier: 2, radius: 0.699999988079071, tele: 2.200000047683716 }, - { tier: 3, radius: 0.800000011920929, tele: 2.5999999046325684 } + { tier: 3, radius: 0.800000011920929, tele: 2.5999999046325684 }, + { tier: 4, radius: 1.2000000476837158, tele: 3.200000047683716 } ], states: ['idle', 'chase', 'windup', 'recover', 'return', 'dead'], attackRange: 2.200000047683716, @@ -40,3 +41,6 @@ export const PIT_WORLD: PitWorld = { maxRoom: 1000000, biomes: ['highlands', 'steppe', 'forest', 'meadow'] }; + +/** `weather`, as the client decodes it. */ +export const WEATHER: readonly string[] = ['storm', 'rain', 'clearing', 'clear']; diff --git a/examples/game/client/src/generated/mobpit/events.ts b/examples/game/client/src/generated/mobpit/events.ts index 4a305927..3363c5b7 100644 --- a/examples/game/client/src/generated/mobpit/events.ts +++ b/examples/game/client/src/generated/mobpit/events.ts @@ -46,6 +46,10 @@ export interface PitRoomLayout { readonly biome: number; readonly danger: number; readonly mobs: readonly RoomMob[]; + readonly boss: boolean; + readonly hoodX: number; + readonly hoodY: number; + readonly bossDistance: number; } /** Record `PitRules` (`#[derive(GameRecord)]`). */ @@ -94,6 +98,40 @@ const PROFILE_TYPE: WireType = { ] }; +/** Record `WorldTiers` (`#[derive(GameRecord)]`). */ +export interface WorldTiers { + readonly hoodX: number; + readonly hoodY: number; + readonly hoodTier: number; + readonly bossDown: boolean; + readonly lastBy: string; + readonly boroughX: number; + readonly boroughY: number; + readonly boroughBossesDown: number; + readonly weather: number; + readonly hoodsDown: readonly string[]; + readonly cityBossesDown: number; + readonly banner: string; +} + +const WORLD_TIERS_TYPE: WireType = { + type: 'record', + fields: [ + ['hoodX', { type: 'i32' }], + ['hoodY', { type: 'i32' }], + ['hoodTier', { type: 'u32' }], + ['bossDown', { type: 'bool' }], + ['lastBy', { type: 'short_str' }], + ['boroughX', { type: 'i32' }], + ['boroughY', { type: 'i32' }], + ['boroughBossesDown', { type: 'u32' }], + ['weather', { type: 'u8' }], + ['hoodsDown', { type: 'list', item: { type: 'short_str' } }], + ['cityBossesDown', { type: 'u32' }], + ['banner', { type: 'short_str' }], + ] +}; + /** Event `hello` (id 1). */ export interface HelloEvent { readonly entity: number; @@ -187,6 +225,22 @@ const DIED_LAYOUT: PayloadLayout = [ ['respawnMs', { type: 'u32' }], ]; +/** Event `world` (id 7). */ +export type WorldEvent = WorldTiers; + +const WORLD_PAYLOAD: WireType = WORLD_TIERS_TYPE; + +/** Event `boss_down` (id 8). */ +export interface BossDownEvent { + readonly by: string; + readonly hoodTier: number; +} + +const BOSS_DOWN_LAYOUT: PayloadLayout = [ + ['by', { type: 'short_str' }], + ['hoodTier', { type: 'u32' }], +]; + /** Message `loaded` (id 1). */ export interface LoadedMessage { readonly principal: string; @@ -202,6 +256,16 @@ const MESSAGE_LOADED_LAYOUT: PayloadLayout = [ ['kills', { type: 'u32' }], ]; +/** Message `world` (id 2). */ +export type WorldMessage = WorldTiers; + +const MESSAGE_WORLD_PAYLOAD: WireType = WORLD_TIERS_TYPE; + +/** Message `world_keep_city` (id 3). */ +export type WorldKeepCityMessage = WorldTiers; + +const MESSAGE_WORLD_KEEP_CITY_PAYLOAD: WireType = WORLD_TIERS_TYPE; + /** Event type ids by name. */ export const EVENT = { hello: 1, @@ -209,6 +273,8 @@ export const EVENT = { kill: 3, saved: 4, died: 6, + world: 7, + bossDown: 8, } as const; /** Event payload types by name. */ @@ -218,6 +284,8 @@ export interface Events { kill: KillEvent; saved: SavedEvent; died: DiedEvent; + world: WorldEvent; + bossDown: BossDownEvent; } /** Codecs of every event. */ @@ -252,6 +320,18 @@ export const EVENTS: GameEventCodecs = { encode: (value) => encodePayload(DIED_LAYOUT, value), decode: (payload) => decodePayload(DIED_LAYOUT, payload) as unknown as DiedEvent }, + world: { + id: 7, + name: 'world', + encode: (value) => encodeValue(WORLD_PAYLOAD, value), + decode: (payload) => decodeValue(WORLD_PAYLOAD, payload) as WorldEvent + }, + bossDown: { + id: 8, + name: 'boss_down', + encode: (value) => encodePayload(BOSS_DOWN_LAYOUT, value), + decode: (payload) => decodePayload(BOSS_DOWN_LAYOUT, payload) as unknown as BossDownEvent + }, }; /** A decoded event, discriminated by `name`. */ @@ -274,11 +354,15 @@ export function onEvent( /** Zone inbox message type ids by name. */ export const MESSAGE = { loaded: 1, + world: 2, + worldKeepCity: 3, } as const; /** Zone inbox message payload types by name. */ export interface Messages { loaded: LoadedMessage; + world: WorldMessage; + worldKeepCity: WorldKeepCityMessage; } /** Codecs of every zone inbox message. */ @@ -289,6 +373,18 @@ export const MESSAGES: GameEventCodecs = { encode: (value) => encodePayload(MESSAGE_LOADED_LAYOUT, value), decode: (payload) => decodePayload(MESSAGE_LOADED_LAYOUT, payload) as unknown as LoadedMessage }, + world: { + id: 2, + name: 'world', + encode: (value) => encodeValue(MESSAGE_WORLD_PAYLOAD, value), + decode: (payload) => decodeValue(MESSAGE_WORLD_PAYLOAD, payload) as WorldMessage + }, + worldKeepCity: { + id: 3, + name: 'world_keep_city', + encode: (value) => encodeValue(MESSAGE_WORLD_KEEP_CITY_PAYLOAD, value), + decode: (payload) => decodeValue(MESSAGE_WORLD_KEEP_CITY_PAYLOAD, payload) as WorldKeepCityMessage + }, }; /** Encode a zone inbox message: `typeId` and `payload` of a `/admin/zone-message` body (base64url the payload). */ diff --git a/examples/game/client/src/generated/mobpit/pures.ts b/examples/game/client/src/generated/mobpit/pures.ts index e2d25d5c..d9002aa6 100644 --- a/examples/game/client/src/generated/mobpit/pures.ts +++ b/examples/game/client/src/generated/mobpit/pures.ts @@ -29,6 +29,10 @@ const PIT_ROOM_LAYOUT_TYPE: WireType = { ['biome', { type: 'u8' }], ['danger', { type: 'u8' }], ['mobs', { type: 'list', item: ROOM_MOB_TYPE }], + ['boss', { type: 'bool' }], + ['hoodX', { type: 'i32' }], + ['hoodY', { type: 'i32' }], + ['bossDistance', { type: 'u8' }], ] }; diff --git a/examples/game/client/src/generated/mobpit/surface.json b/examples/game/client/src/generated/mobpit/surface.json index 8fa40e36..bb630ce6 100644 --- a/examples/game/client/src/generated/mobpit/surface.json +++ b/examples/game/client/src/generated/mobpit/surface.json @@ -33,6 +33,11 @@ "radius": 0.800000011920929, "tele": 2.5999999046325684, "tier": 3 + }, + { + "radius": 1.2000000476837158, + "tele": 3.200000047683716, + "tier": 4 } ] } @@ -107,6 +112,21 @@ "player_radius": 0.5, "room_size": 40.0 } + }, + { + "name": "weather", + "type": { + "item": { + "type": "short_str" + }, + "type": "list" + }, + "value": [ + "storm", + "rain", + "clearing", + "clear" + ] } ], "events": [ @@ -339,6 +359,38 @@ ], "kind": "fields" } + }, + { + "id": 7, + "name": "world", + "payload": { + "kind": "value", + "type": { + "name": "WorldTiers", + "type": "record" + } + } + }, + { + "id": 8, + "name": "boss_down", + "payload": { + "fields": [ + { + "name": "by", + "type": { + "type": "short_str" + } + }, + { + "name": "hood_tier", + "type": { + "type": "u32" + } + } + ], + "kind": "fields" + } } ], "input": { @@ -709,6 +761,28 @@ ], "kind": "fields" } + }, + { + "id": 2, + "name": "world", + "payload": { + "kind": "value", + "type": { + "name": "WorldTiers", + "type": "record" + } + } + }, + { + "id": 3, + "name": "world_keep_city", + "payload": { + "kind": "value", + "type": { + "name": "WorldTiers", + "type": "record" + } + } } ], "name": "mobpit", @@ -869,6 +943,30 @@ }, "type": "list" } + }, + { + "name": "boss", + "type": { + "type": "bool" + } + }, + { + "name": "hood_x", + "type": { + "type": "i32" + } + }, + { + "name": "hood_y", + "type": { + "type": "i32" + } + }, + { + "name": "boss_distance", + "type": { + "type": "u8" + } } ], "name": "PitRoomLayout" @@ -1056,6 +1154,86 @@ } ], "name": "RoomMob" + }, + { + "fields": [ + { + "name": "hood_x", + "type": { + "type": "i32" + } + }, + { + "name": "hood_y", + "type": { + "type": "i32" + } + }, + { + "name": "hood_tier", + "type": { + "type": "u32" + } + }, + { + "name": "boss_down", + "type": { + "type": "bool" + } + }, + { + "name": "last_by", + "type": { + "type": "short_str" + } + }, + { + "name": "borough_x", + "type": { + "type": "i32" + } + }, + { + "name": "borough_y", + "type": { + "type": "i32" + } + }, + { + "name": "borough_bosses_down", + "type": { + "type": "u32" + } + }, + { + "name": "weather", + "type": { + "type": "u8" + } + }, + { + "name": "hoods_down", + "type": { + "item": { + "type": "short_str" + }, + "type": "list" + } + }, + { + "name": "city_bosses_down", + "type": { + "type": "u32" + } + }, + { + "name": "banner", + "type": { + "type": "short_str" + } + } + ], + "name": "WorldTiers" } ], "schemaVersion": 3 diff --git a/examples/game/client/src/mobpit/client.ts b/examples/game/client/src/mobpit/client.ts index d641bb1e..5feac26c 100644 --- a/examples/game/client/src/mobpit/client.ts +++ b/examples/game/client/src/mobpit/client.ts @@ -100,7 +100,8 @@ export const MOB_STATE = pit.PIT_RULES.states; const TIER_LOOK: Readonly> = { 1: { name: 'Grunt', color: '#9ad16b' }, 2: { name: 'Brute', color: '#b48cf2' }, - 3: { name: 'Elite', color: '#ff6b6b' } + 3: { name: 'Elite', color: '#ff6b6b' }, + 4: { name: 'Boss', color: '#ff2d55' } }; /** * Per tier: display name and color (client presentation), body and @@ -152,6 +153,13 @@ export type RoomStatus = Readonly<{ changedAtMs: number; }>; +/** The tiers around a room (`/dev/mobpit/tiers`): hoods, their boroughs, the city. */ +export type TiersAround = Readonly<{ + hoods: { hood: string; x: number; y: number; bossRoom: [number, number]; bossDown: boolean; tier: number }[]; + boroughs: { borough: string; x: number; y: number; bossesDown: number; weather: string }[]; + city: { bosses_down: number; boroughs_cleared: number; banner: string } | null; +}>; + /** A room object's debug JSON (`/rooms/{room}/info`). */ export type RoomInfo = Readonly<{ zone: string; @@ -172,6 +180,8 @@ export type RoomInfo = Readonly<{ export type MobPitClient = pit.MobpitClient & Readonly<{ hello(): pit.HelloEvent | undefined; + /** The tiers above the room (hood, borough, city), latest. */ + world(): pit.WorldTiers | undefined; profile(): pit.Profile | undefined; /** The room the player is in (authority zone). */ room(): string | undefined; @@ -216,6 +226,11 @@ export function createMobPitClient(options: MobPitClientOptions): MobPitClient { (value) => typed.client.setControlledEntity(value.entity) ); const profile = latest((h) => typed.on('profile', h)); + // Only the own room's view of the tiers (watched neighbours send theirs too). + let world: pit.WorldTiers | undefined; + typed.on('world', (w, zoneId) => { + if (zoneId === typed.client.authority?.zoneId) world = w; + }); const unsaved = new Map(); typed.on('kill', (kill, zoneId) => { // Entity ids are per room: only kills in your own room can be yours. @@ -235,6 +250,7 @@ export function createMobPitClient(options: MobPitClientOptions): MobPitClient { return { ...typed, hello: hello.get, + world: () => world, profile: profile.get, room: () => typed.client.authority?.zoneId, me, @@ -309,6 +325,11 @@ export class MobPitApi { return parseRoom(zone) === undefined ? DEFAULT_ZONE : zone; } + /** The hoods around `room`, their boroughs and the city (tier cells). */ + tiers(room: string): Promise { + return this.#json(`/dev/mobpit/tiers?room=${encodeURIComponent(room)}`); + } + /** Every room the zone status read model knows. */ rooms(): Promise<{ rooms: RoomStatus[] }> { return this.#json('/dev/mobpit/rooms'); diff --git a/examples/game/client/src/mobpit/main.ts b/examples/game/client/src/mobpit/main.ts index 1319e4b4..e9b2dda4 100644 --- a/examples/game/client/src/mobpit/main.ts +++ b/examples/game/client/src/mobpit/main.ts @@ -26,7 +26,8 @@ */ import type { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial'; import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture'; -import { Color3 } from '@babylonjs/core/Maths/math.color'; +import { Color3, Color4 } from '@babylonjs/core/Maths/math.color'; +import { Scene } from '@babylonjs/core/scene'; import { Vector3 } from '@babylonjs/core/Maths/math.vector'; import { Mesh } from '@babylonjs/core/Meshes/mesh'; import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder'; @@ -41,6 +42,7 @@ import { isPillar, isPit, mobFields, + WEATHER, pitFields, playerFields, type PitFields, @@ -61,7 +63,8 @@ import { TIERS, type BoardSnapshot, type RoomInfo, - type RoomStatus + type RoomStatus, + type TiersAround } from './client.ts'; /** The rooms' floor, generated from the simulation's constants (`game_sim::mobpit::pit_world`). */ @@ -96,6 +99,41 @@ const canvas = document.getElementById('stage') as HTMLCanvasElement; const stage = createStage(canvas, { half: HALF, background: '#120f14' }); const { scene } = stage; +// ---- weather and darkness (Boss Ripple's sky, now the borough's) ---- +// The borough's weather lifts as its bosses fall (storm → rain → clearing → +// clear); within it, the fog thickens and the light dims the closer the room +// is to a boss room (`boss_distance`, the shared Rust room generator). +scene.fogMode = Scene.FOGMODE_EXP2; +const SKY: Record = { + storm: { clear: '#141821', fog: 0.035, light: 0.35 }, + rain: { clear: '#2a3442', fog: 0.022, light: 0.55 }, + clearing: { clear: '#56708c', fog: 0.012, light: 0.8 }, + clear: { clear: '#8fc3f0', fog: 0.004, light: 1.1 } +}; +const lights = scene.lights.map((l) => ({ light: l, base: l.intensity })); +/** Fog and darkness of the current room: its borough's weather × closeness to a boss. */ +function roomSky(): { clear: string; fog: number; light: number; near: number } { + const sky = SKY[WEATHER[pit.world()?.weather ?? 0] ?? 'storm'] ?? SKY.storm!; + const here = parseRoom(pit.room() ?? DEFAULT_ZONE); + const seed = pit.hello()?.seed ?? PIT_WORLD.defaultSeed; + const distance = here !== undefined && pit.pures.loaded() ? pit.pures.pitRoom({ seed, x: here[0], y: here[1] }).bossDistance : 6; + // 1 in a boss room, 0 six rooms away. + const near = Math.max(0, 1 - distance / 6); + return { clear: sky.clear, fog: sky.fog * (1 + 2 * near), light: sky.light * (1 - 0.45 * near), near }; +} +function applySky(dtMs: number): void { + const target = roomSky(); + const want = Color4.FromHexString(`${target.clear}ff`).scale(1 - 0.5 * target.near); + const k = 1 - Math.exp(-dtMs / 600); + scene.clearColor = Color4.Lerp(scene.clearColor, new Color4(want.r, want.g, want.b, 1), k); + scene.fogColor = new Color3(scene.clearColor.r, scene.clearColor.g, scene.clearColor.b); + scene.fogDensity += (target.fog - scene.fogDensity) * k; + const storm = (pit.world()?.weather ?? 0) === 0; + const flash = storm && Math.random() < 0.004 ? 2.5 : 1; + for (const { light, base } of lights) light.intensity += (base * target.light * flash - light.intensity) * Math.min(1, k * (flash > 1 ? 20 : 1)); + canvas.dataset.fog = scene.fogDensity.toFixed(4); +} + const hud = new Hud({ title: 'Mob Pit', hint: 'WASD move · Space attack · E dash · walk through a doorway to the next room · O debug overlay · P prediction · G server ghost. Rooms run on celld; kills are durable facts.', @@ -191,6 +229,16 @@ async function pollRoomInfo(): Promise { } void pollRoomInfo(); +let tiers: TiersAround | undefined; +async function pollTiers(): Promise { + for (;;) { + const here = pit.room(); + if (here !== undefined) tiers = await pitApi.tiers(here).catch(() => tiers); + await new Promise((r) => setTimeout(r, 2000)); + } +} +void pollTiers(); + function drawRooms(): void { const ctx = roomsMap.getContext('2d'); const here = parseRoom(pit.room() ?? DEFAULT_ZONE); @@ -225,7 +273,37 @@ function drawRooms(): void { ctx.font = '10px monospace'; if (row !== undefined && row.players > 0) ctx.fillText(`${row.players}p`, i * cell + 5, j * cell + 13); if (debug && row !== undefined && row.node !== '') ctx.fillText(row.node.replace(/^node-/, ''), i * cell + 5, j * cell + cell - 6); + // Boss rooms: a red mark (grey while the boss is down) and the hood's tier. + const hood = tiers?.hoods.find((h) => h.bossRoom[0] === x && h.bossRoom[1] === y); + if (hood !== undefined) { + ctx.fillStyle = hood.bossDown ? '#777' : '#ff2d55'; + ctx.beginPath(); + ctx.arc(i * cell + cell - 9, j * cell + 9, 5, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#fff'; + ctx.fillText(`T${hood.tier}`, i * cell + cell - 22, j * cell + cell - 6); + } + } + } + // Hood (3 × 3 rooms) and borough (9 × 9) borders. + const line = (fixed: number, vertical: boolean, color: string, width: number) => { + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.beginPath(); + if (vertical) { + ctx.moveTo(fixed, 0); + ctx.lineTo(fixed, roomsMap.height); + } else { + ctx.moveTo(0, fixed); + ctx.lineTo(roomsMap.width, fixed); } + ctx.stroke(); + }; + for (let i = 0; i <= span; i++) { + const x = here[0] + i - 3; + if (((x % 3) + 3) % 3 === 0) line(i * cell, true, ((x % 9) + 9) % 9 === 0 ? '#ffd166' : '#8a6dd1', ((x % 9) + 9) % 9 === 0 ? 3 : 1.5); + const y = here[1] - (i - 4) ; + if (((y % 3) + 3) % 3 === 0) line(i * cell, false, ((y % 9) + 9) % 9 === 0 ? '#ffd166' : '#8a6dd1', ((y % 9) + 9) % 9 === 0 ? 3 : 1.5); } } @@ -478,7 +556,9 @@ function createMob(entity: RenderedEntity): MobVisual { const tier = mobFields(entity).tier || 1; const t = TIERS[tier] ?? TIERS[1]!; const body = - tier === 3 + tier === 4 + ? MeshBuilder.CreateCylinder(entity.key, { diameterTop: t.radius * 0.8, diameterBottom: t.radius * 2, height: 2.8, tessellation: 8 }, scene) + : tier === 3 ? MeshBuilder.CreateCylinder(entity.key, { diameterTop: 0, diameterBottom: t.radius * 2, height: 2, tessellation: 6 }, scene) : MeshBuilder.CreateBox(entity.key, { width: t.radius * 2, depth: t.radius * 2, height: tier === 2 ? 1.5 : 0.9 }, scene); body.material = tierMats.get(tier) ?? mats.other; @@ -549,6 +629,15 @@ pit.on('saved', (saved, zoneId) => { popup(saved.key, zoneId, saved.x, saved.y, `save failed: ${saved.error ?? '?'}`, 'failed', 4000); } }); +pit.on('bossDown', (b, zoneId) => { + if (zoneId === pit.room()) toast(`${b.by} felled the boss! The hood is now tier ${b.hoodTier} — its rooms get tougher`); +}); +let lastWeather: number | undefined; +pit.on('world', (w, zoneId) => { + if (zoneId !== pit.room()) return; + if (lastWeather !== undefined && w.weather > lastWeather) toast(`The sky over the borough lifts: ${WEATHER[w.weather]}`); + lastWeather = w.weather; +}); pit.on('died', (d) => { deathBanner.textContent = 'You died — respawning…'; deathBanner.style.display = ''; @@ -626,6 +715,7 @@ function sendCommandFrame(): void { // ---- frame ---- stage.onFrame((now, dtMs) => { + applySky(dtMs); commandFrames.update(now, sendCommandFrame); const here = pit.room(); const hereRoom = here === undefined ? undefined : parseRoom(here); @@ -653,7 +743,7 @@ stage.onFrame((now, dtMs) => { if (visual.kind === 'mob') { const mob = mobFields(entity); const { x, y } = mob; - const height = visual.tier === 3 ? 1 : visual.tier === 2 ? 0.75 : 0.45; + const height = visual.tier === 4 ? 1.4 : visual.tier === 3 ? 1 : visual.tier === 2 ? 0.75 : 0.45; visual.body.position.copyFrom(at(x, y, height)); visual.body.rotation.y = -mob.face; const hp = mob.hp / 100; @@ -749,6 +839,10 @@ stage.onFrame((now, dtMs) => { hud.row('Ghost gap', ghostGap === undefined ? '—' : `${ghostGap.toFixed(2)} m`); const around = [...pit.neighbours().values()]; hud.row('Next door', `${around.filter((s) => s === 'watching').length} rooms watched · ${around.filter((s) => s === 'asleep').length} asleep (not woken)`); + const w = pit.world(); + hud.row('Hood', w === undefined ? '—' : `${w.hoodX},${w.hoodY} · tier ${w.hoodTier} · boss ${w.bossDown ? `down (${w.lastBy})` : 'standing'}`); + hud.row('Borough', w === undefined ? '—' : `${w.boroughX},${w.boroughY} · ${WEATHER[w.weather]} · ${w.boroughBossesDown}/9 bosses down`); + hud.row('City', w === undefined || w.banner === '' ? '—' : w.banner); hud.row('Interest', hello === undefined ? '—' : `${hello.viewRadius} m ring · ${rendered.filter((e) => isMob(e) && !e.stale).length} mobs replicated`); const profile = pit.profile(); if (profile !== undefined) { diff --git a/examples/game/client/tests/mobpit-bosses-headless.test.mjs b/examples/game/client/tests/mobpit-bosses-headless.test.mjs new file mode 100644 index 00000000..baddfb93 --- /dev/null +++ b/examples/game/client/tests/mobpit-bosses-headless.test.mjs @@ -0,0 +1,107 @@ +/** + * Headless Mob Pit hierarchy (Node): rooms and tier cells on their own + * `celld dev`, the services binary, a party of scripted players. + * + * A party fells the boss of hood 0.0 in its seeded boss room: the room + * tells everyone (`bossDown`) and reports the fall to the hood's cell (a + * zone fact); the hood's tier rises and the worker's saga ripples it into + * the borough and the city (`/dev/mobpit/tiers` reads the cells). Another + * room of the same hood learns its tier from its hood (the rooms read + * their tiers off the tick) — it is tougher now. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { BUTTON, isMob, mobFields, playerFields } from '../src/generated/mobpit/index.ts'; +import { createMobPitClient, MobPitApi } from '../src/mobpit/client.ts'; +import { DevApi } from '../src/shared/dev-api.ts'; +import { eventually, memoryStorage, sleep, until } from './helpers/demo-server.mjs'; +import { startPit } from './helpers/mobpit.mjs'; + +/** Boss room of hood `(hx, hy)`: the room whose generated layout says so. */ +function bossRoomOf(pures, seed, hx, hy) { + for (let k = 0; k < 9; k += 1) { + const [x, y] = [hx * 3 + (k % 3), hy * 3 + Math.floor(k / 3)]; + if (pures.pitRoom({ seed, x, y }).boss) return [x, y]; + } + throw new Error('no boss room'); +} + +test('a boss falls: its hood rises, the saga ripples it up, the hood gets tougher', { timeout: 600_000 }, async () => { + const server = await startPit('mobpit'); + const clients = []; + try { + const pitApi = new MobPitApi(server.httpUrl, server.roomsUrl); + const player = (name) => { + const pit = createMobPitClient({ + api: new DevApi({ base: server.httpUrl, name: () => name, storage: memoryStorage() }), + roomsBase: server.roomsUrl, + neighbours: false + }); + clients.push(pit); + return pit; + }; + const scout = player('Scout'); + await scout.pures.ready; + const seed = (await import('../src/generated/mobpit/index.ts')).PIT_WORLD.defaultSeed; + const [bx, by] = bossRoomOf(scout.pures, seed, 0, 0); + const bossZone = `pit.${bx}.${by}`; + const party = ['Ana', 'Bob', 'Cy', 'Di'].map(player); + await Promise.all(party.map((p) => p.client.connect(bossZone))); + await Promise.all(party.map((p) => p.ready)); + const downs = []; + party[0].on('bossDown', (b, zone) => zone === bossZone && downs.push(b)); + await until(() => party[0].world() !== undefined, 10_000, 'world tiers on join'); + assert.equal(party[0].world().hoodTier, 0); + const boss = () => party[0].client.render().find((e) => isMob(e) && e.zoneId === bossZone && mobFields(e).tier === 4 && !e.stale); + await until(() => boss() !== undefined, 10_000, 'the boss'); + + // The party hacks at the boss until it falls. + const deadline = Date.now() + 120_000; + while (downs.length === 0) { + assert.ok(Date.now() < deadline, 'the party fells the boss'); + const b = boss(); + for (const p of party) { + const me = p.me(); + let input = { mx: 0, my: 0, buttons: 0 }; + if (b !== undefined && me !== undefined) { + const at = playerFields(me); + const target = mobFields(b); + const dx = target.x - at.x; + const dy = target.y - at.y; + const d = Math.max(Math.hypot(dx, dy), 1e-3); + const near = d < 2.6; + const k = (near ? 40 : 127) / d; + input = { mx: Math.round(dx * k), my: Math.round(dy * k), buttons: near ? BUTTON.attack : 0 }; + } + p.client.sendInput(input); + } + await sleep(1000 / 30); + } + assert.equal(downs[0].hoodTier, 1); + console.error(`boss of hood 0.0 felled in ${bossZone} by ${downs[0].by}`); + + // Up the hierarchy: the hood's cell, then the saga into borough and city. + const tiers = await eventually(async () => { + const t = await pitApi.tiers(bossZone); + const hood = t.hoods.find((h) => h.hood === '0.0'); + return hood?.bossDown && hood.tier === 1 && t.boroughs.find((b) => b.borough === '0.0')?.bossesDown === 1 && t.city?.bosses_down === 1 && t; + }, 30_000, 'hood → borough → city'); + console.error(`city: ${tiers.city.banner}`); + + // Down again: another room of the hood reads its hood's tier. + const other = [0, 1, 2].flatMap((x) => [0, 1, 2].map((y) => [x, y])).find(([x, y]) => x !== bx || y !== by); + const visitor = player('Visitor'); + await visitor.client.connect(`pit.${other[0]}.${other[1]}`); + await visitor.ready; + await until(() => visitor.world()?.hoodTier === 1 && visitor.world()?.bossDown === true, 15_000, 'the hood tier reaches its other rooms'); + assert.equal(visitor.world().boroughBossesDown, 1); + assert.ok(visitor.world().banner.length > 0, 'the city banner'); + // The boss stays down in its room meanwhile. + await sleep(5000); + assert.equal(boss(), undefined, 'still down'); + } finally { + for (const c of clients) c.close(); + await server.stop(); + } +}); diff --git a/examples/game/client/tests/prediction-wasm.test.mjs b/examples/game/client/tests/prediction-wasm.test.mjs index 29f212c7..93b77042 100644 --- a/examples/game/client/tests/prediction-wasm.test.mjs +++ b/examples/game/client/tests/prediction-wasm.test.mjs @@ -59,7 +59,7 @@ const surfaces = readdirSync(GENERATED) }); test('every predicting demo has a compiled step and a native trace', () => { - assert.deepEqual(surfaces.map((s) => s.name).sort(), ['freeze', 'mobpit', 'ripple']); + assert.deepEqual(surfaces.map((s) => s.name).sort(), ['freeze', 'mobpit']); assert.equal(traces.abi, 1); for (const { name, predict } of surfaces) { assert.ok(existsSync(new URL(`${name}/wasm/${name}.wasm`, GENERATED)), `${name}: run \`run.sh gen\``); @@ -193,7 +193,7 @@ test('cost: generating a room in WASM', async () => { test('cost: one predicted step and a reconciliation replaying 10 inputs at 30 Hz', async () => { const report = {}; - for (const name of ['mobpit', 'ripple']) { + for (const name of ['mobpit', 'freeze']) { const generated = await import(`../src/generated/${name}/index.ts`); const prediction = generated[`create${name[0].toUpperCase()}${name.slice(1)}Prediction`](); await prediction.ready; diff --git a/examples/game/client/vite.config.ts b/examples/game/client/vite.config.ts index 58cea9da..672b4b6a 100644 --- a/examples/game/client/vite.config.ts +++ b/examples/game/client/vite.config.ts @@ -7,7 +7,7 @@ import { resolve } from 'node:path'; import { defineConfig } from 'vite'; -const DEMOS = ['mobpit', 'leaderboard', 'gallery', 'freeze', 'ripple']; +const DEMOS = ['mobpit', 'leaderboard', 'gallery', 'freeze']; const server = process.env.GAME_DEMOS_SERVER ?? 'http://127.0.0.1:2567'; const rooms = process.env.GAME_DEMOS_ROOMS ?? 'http://127.0.0.1:2570'; diff --git a/examples/game/run.sh b/examples/game/run.sh index cea639d6..f6c1b29d 100755 --- a/examples/game/run.sh +++ b/examples/game/run.sh @@ -10,8 +10,6 @@ # 3) in production mode on one RustFS bucket # (docker compose), entered round-robin, so rooms # land on different nodes (debug overlay shows which) -# ./run.sh ripple Boss Ripple with TWO hosts (A on GAME_DEMOS_PORT, B on -# the next port), one process each # ./run.sh gen regenerate client/src/generated// from the # Rust surfaces (`distributed game-manifest` + # `distributed game-client`) and compile each @@ -41,16 +39,15 @@ # MOBPIT_SERVICE_PORT, default GAME_DEMOS_PORT + 2). # # celld: Mob Pit's rooms and the demos' durable cells (examples/game/cells: -# Mob Pit's rooms, Crawler, presence and zone-activity cells, The Freeze's -# gem and content cells, every multi-host demo's zone leases and -# hibernation snapshots) run on celld, never natively; state persists in +# Mob Pit's rooms, Crawler, presence, zone-activity and hood / borough / +# city tier cells, The Freeze's gem and content cells) run on celld, never natively; state persists in # .data//celld (Mob Pit: MOBPIT_DATA_DIR/celld, the fleet's bucket in # a RustFS volume of its compose project). Fleet ports: nodes' public # listeners GAME_DEMOS_PORT + 10.., operators + 20.., RustFS + 30 # (MOBPIT_FLEET_S3_PORT); `docker compose down -v` runs on exit. CELLD_BIN names the binary (default: `celld` on # PATH; the version pinned in .celld-version, checked by # scripts/celld.sh), CELLD_PORT its port (default -# GAME_DEMOS_PORT + 3; cluster demos GAME_DEMOS_PORT + 4), +# GAME_DEMOS_PORT + 3), # CELLD_ESBUILD its bundler (default: the client's esbuild). The worker is # built with worker-build (`cargo install worker-build --locked`). set -euo pipefail @@ -63,16 +60,13 @@ SVELTEKIT="$HERE/sveltekit" BIN="$SERVER/target/debug/game-demos" SERVICE_BIN="$SERVER/target/debug/mobpit-service" CELLS="$HERE/cells" -DEMOS=(mobpit leaderboard gallery freeze ripple) -# Single-process demos whose durable state lives in celld cells (the -# multi-host ones always run on celld too: zone leases and hibernation). +DEMOS=(mobpit leaderboard gallery freeze) +# Demos whose durable state lives in celld cells. CELLD_DEMOS=(mobpit leaderboard freeze) export MOBPIT_DATA_DIR="${MOBPIT_DATA_DIR:-$HERE/.data/mobpit}" -# Multi-host demos: `game-demos --host-id A|B --peer …`. -CLUSTER_DEMOS=(ripple) # Game surfaces with a generated client (`game_demos::::game_surface`); # the leaderboard page uses Mob Pit's. -SURFACES=(mobpit gallery freeze ripple) +SURFACES=(mobpit gallery freeze) CLI="$REPO/target/debug/distributed" log() { printf '\033[1;36m[demos]\033[0m %s\n' "$*"; } @@ -456,44 +450,6 @@ wait_listening() { done } -run_cluster() { - local demo="$1" - local port_a="${GAME_DEMOS_PORT:-2567}" - local port_b=$((port_a + 1)) - local client_port="${GAME_DEMOS_CLIENT_PORT:-5173}" - ensure_sdk - ensure_client - ensure_server - ensure_wasm - - # Zone leases and hibernation snapshots live in celld (shared by both - # hosts); its state persists in .data//celld. - start_celld "$HERE/.data/$demo/celld" "${CELLD_PORT:-$((port_a + 4))}" "$port_a" - local dir secret - dir="$(mktemp -d "${TMPDIR:-/tmp}/game-demos-$demo.XXXXXX")" - # Both hosts must share the ticket secret (and derive the admin secret). - secret="${DISTRIBUTED_GAME_SECRET:-$(dev_secret "$HERE/.data/$demo")}" - # The relay lands on host A; both hosts share the projection bus and keep - # their zone status read models in .data/. - DISTRIBUTED_GAME_SECRET="$secret" "$BIN" "$demo" --dev --port "$port_a" --host-id A \ - --peer "B=127.0.0.1:$port_b" --data-dir "$HERE/.data/$demo" >"$dir/A.log" 2>&1 & - local pid_a=$! - DISTRIBUTED_GAME_SECRET="$secret" "$BIN" "$demo" --dev --port "$port_b" --host-id B \ - --peer "A=127.0.0.1:$port_a" --data-dir "$HERE/.data/$demo" >"$dir/B.log" 2>&1 & - local pid_b=$! - trap 'kill "$pid_a" "$pid_b" 2>/dev/null || true; wait "$pid_a" "$pid_b" 2>/dev/null || true; kill_tree "$CELLD_PID"; rm -rf "$dir"' EXIT INT TERM - wait_listening "$dir/A.log" "$pid_a" "game-demos $demo host A" - wait_listening "$dir/B.log" "$pid_b" "game-demos $demo host B" - sed -n '2p' "$dir/A.log" - sed -n '2p' "$dir/B.log" - echo - log "open http://localhost:$client_port/$demo/ (hosts A :$port_a, B :$port_b, dev routes ON — local only)" - log "zone leases and hibernation snapshots in celld ($CELLD_URL); host logs in $dir (removed on exit)" - echo - cd "$CLIENT" - GAME_DEMOS_SERVER="http://127.0.0.1:$port_a" GAME_DEMOS_CLIENT_PORT="$client_port" npx vite -} - # api_keys: an ES256 key pair for the SvelteKit server's read-model Bearer # tokens — sets API_SIGNING_JWK (private, for the UI) and API_JWKS (public, # for mobpit-service). Fresh per run; nothing is written to disk. @@ -579,7 +535,7 @@ run_sveltekit() { smoke() { ensure_sdk ensure_client - # The celld-backed tests (Mob Pit, The Freeze, ripple) start their + # The celld-backed tests (Mob Pit, The Freeze) start their # own `celld dev` (CELLD_BIN) on the cells worker. ensure_celld ensure_cells @@ -591,7 +547,7 @@ smoke() { log "generated clients are current; predicted steps and pure functions compiled to WASM" gen --check log "server: fmt, clippy, tests (bots smoke)" - cargo fmt --manifest-path "$SERVER/Cargo.toml" --check -p game_demos -p game_sim -p mobpit_crawler -p mobpit_service + cargo fmt --manifest-path "$SERVER/Cargo.toml" --check -p game_demos -p game_sim -p mobpit_crawler -p mobpit_service -p mobpit_rooms -p mobpit_tiers cargo fmt --manifest-path "$CELLS/Cargo.toml" --check cargo clippy --quiet --manifest-path "$SERVER/Cargo.toml" --workspace --all-targets -- -D warnings cargo clippy --quiet --manifest-path "$CELLS/Cargo.toml" --target wasm32-unknown-unknown -- -D warnings @@ -619,9 +575,7 @@ case "${1:-}" in list) printf '%s\n' "${DEMOS[@]}" sveltekit ;; "" | -h | --help | help) awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$0" ;; *) - if [[ " ${CLUSTER_DEMOS[*]} " == *" $1 "* ]]; then - run_cluster "$1" - elif [[ "$1" == mobpit || "$1" == leaderboard ]]; then + if [[ "$1" == mobpit || "$1" == leaderboard ]]; then run_mobpit "$1" "${2:-}" "${3:-3}" elif [[ " ${DEMOS[*]} " == *" $1 "* ]]; then run_demo "$1" diff --git a/examples/game/server/Cargo.lock b/examples/game/server/Cargo.lock index 54d7518d..920df9ac 100644 --- a/examples/game/server/Cargo.lock +++ b/examples/game/server/Cargo.lock @@ -1136,6 +1136,7 @@ dependencies = [ "http-body-util", "mobpit_crawler", "mobpit_rooms", + "mobpit_tiers", "reqwest", "serde", "serde_json", @@ -1744,6 +1745,7 @@ dependencies = [ "distributed_game", "game_sim", "mobpit_crawler", + "mobpit_tiers", "serde", "serde_json", ] @@ -1765,6 +1767,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "mobpit_tiers" +version = "0.1.0" +dependencies = [ + "distributed", + "distributed_game", + "game_sim", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "multer" version = "3.1.0" diff --git a/examples/game/server/Cargo.toml b/examples/game/server/Cargo.toml index 7ee5b13d..746b1090 100644 --- a/examples/game/server/Cargo.toml +++ b/examples/game/server/Cargo.toml @@ -2,7 +2,7 @@ # Own workspace (like tests/gateway-native) so demo-only dependencies never # reach the framework's lockfile; excluded from the root workspace. [workspace] -members = [".", "sim", "crawler", "rooms", "service"] +members = [".", "sim", "crawler", "rooms", "service", "tiers"] resolver = "2" # `distributed build|dev examples/game/server` owns the SvelteKit demo's @@ -32,6 +32,8 @@ mobpit_crawler = { path = "crawler" } # Mob Pit's room zone (hosted on celld; linked here for the client surface, # tests and bots). mobpit_rooms = { path = "rooms" } +# The world tiers (hood / borough / city cells) the minimap reads. +mobpit_tiers = { path = "tiers" } distributed_game = { path = "../../../distributed_game", features = ["net", "cell", "celld"] } # Mob Pit: sorted-set leaderboards (Redis optional at runtime), the SQLite # projection bus and the modeled projector of the crawler directory diff --git a/examples/game/server/crawler/src/lib.rs b/examples/game/server/crawler/src/lib.rs index 0f98f9dc..bd3ea88a 100644 --- a/examples/game/server/crawler/src/lib.rs +++ b/examples/game/server/crawler/src/lib.rs @@ -54,6 +54,8 @@ pub fn xp_for_tier(tier: u8) -> Option { 1 => Some(10), 2 => Some(25), 3 => Some(60), + // A hood's boss. + 4 => Some(200), _ => None, } } @@ -208,7 +210,7 @@ pub type MobKilledArgs = (String, String, String, u8, u32); pub struct RecordKillInput { /// Mob instance (`{zone}/m{id}`). pub mob: String, - /// Mob tier (1..=3); XP comes from [`xp_for_tier`]. + /// Mob tier (1..=3, 4 a boss); XP comes from [`xp_for_tier`]. pub tier: u8, /// Display name at the time of the kill (server-sanitized). pub name: String, diff --git a/examples/game/server/rooms/Cargo.toml b/examples/game/server/rooms/Cargo.toml index 31212d99..33445841 100644 --- a/examples/game/server/rooms/Cargo.toml +++ b/examples/game/server/rooms/Cargo.toml @@ -21,8 +21,10 @@ celld = ["distributed_game/celld-zone"] distributed_game = { path = "../../../../distributed_game" } game_sim = { path = "../sim" } mobpit_crawler = { path = "../crawler" } +mobpit_tiers = { path = "../tiers" } serde = { version = "1", features = ["derive"] } serde_json = "1" [dev-dependencies] distributed_game = { path = "../../../../distributed_game", features = ["cell"] } +serde_json = "1" diff --git a/examples/game/server/rooms/src/celld.rs b/examples/game/server/rooms/src/celld.rs index 16b7dacf..f42b54b6 100644 --- a/examples/game/server/rooms/src/celld.rs +++ b/examples/game/server/rooms/src/celld.rs @@ -7,8 +7,10 @@ use distributed_game::command_sink::FactRoute; use distributed_game::HostConfig; use mobpit_crawler::{RecordKillInput, CRAWLER_KIND, PRESENCE_KIND}; -use crate::zone::{MobPit, PitMessage, RoomConfig}; +use crate::zone::{MobPit, PitMessage, RoomConfig, WorldTiers}; use crate::{DEFAULT_SEED, SEED_VAR}; +use game_sim::mobpit::pair_shard; +use mobpit_tiers::{BossDefeatedInput, BOROUGH_KIND, CITY_KIND, CITY_SHARD, HOOD_KIND}; impl CelldZone for MobPit { fn object_config(zone_id: &str, env: &ObjectEnv) -> Result { @@ -30,9 +32,73 @@ impl CelldZone for MobPit { fn fact_routes(sink: ObjectFactSink) -> ObjectFactSink { sink.route(FactRoute::by_principal::(CRAWLER_KIND)) + .route(FactRoute::by_input_field::( + HOOD_KIND, "hood", + )) .lifecycle_route(PRESENCE_KIND) } + /// The tiers above the room: its hood and borough every refresh (2 s), + /// the city every 5th. + fn refresh_every() -> Option { + Some(std::time::Duration::from_secs(2)) + } + + fn refresh( + env: &ObjectEnv, + zone_id: &str, + round: u64, + ) -> Option>> { + let room = game_sim::mobpit::parse_room(zone_id)?; + let hood = game_sim::mobpit::hood_of(room); + let borough = game_sim::mobpit::borough_of(hood); + let env = env.clone(); + Some(Box::pin(async move { + let hood_state = env.read_cell(HOOD_KIND, &pair_shard(hood)).await.ok()?; + let borough_state = env + .read_cell(BOROUGH_KIND, &pair_shard(borough)) + .await + .ok()?; + let city = if round % 5 == 0 { + env.read_cell(CITY_KIND, CITY_SHARD).await.ok() + } else { + None + }; + let h = &hood_state["state"]; + let b = &borough_state["state"]; + let mut world = WorldTiers { + hood_tier: h["tier"].as_u64().unwrap_or(0) as u32, + boss_down: h["boss_down"].as_bool().unwrap_or(false), + last_by: h["last_by"].as_str().unwrap_or_default().to_string(), + borough_bosses_down: b["bosses_down"].as_u64().unwrap_or(0) as u32, + weather: b["weather"].as_u64().unwrap_or(0) as u8, + hoods_down: b["hoods_down"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(), + ..WorldTiers::default() + }; + match city { + Some(c) => { + world.city_bosses_down = c["state"]["bosses_down"].as_u64().unwrap_or(0) as u32; + world.banner = c["state"]["banner"] + .as_str() + .unwrap_or("The bosses hold the city") + .to_string(); + } + None => { + // Not read this round: keep what the room knows. + return Some(PitMessage::WorldKeepCity(world)); + } + } + Some(PitMessage::World(world)) + })) + } + /// Read the player's Crawler cell (off the tick) for its level and XP. fn after_join(env: &ObjectEnv, principal: &str) -> Option>> { let env = env.clone(); diff --git a/examples/game/server/rooms/src/zone.rs b/examples/game/server/rooms/src/zone.rs index 5a7563be..d80deeb7 100644 --- a/examples/game/server/rooms/src/zone.rs +++ b/examples/game/server/rooms/src/zone.rs @@ -57,10 +57,11 @@ use distributed_game::{ }; use game_sim::arena::{self as movement, MoveState, PILLARS, PLAYER_RADIUS}; use game_sim::mobpit::{ - self as rooms, admissible, crossing, into_neighbour, parse_room, pit_room, pit_world, - room_zone, PitRoomContext, PitRoomFn, + self as rooms, admissible, borough_of, crossing, hood_of, into_neighbour, pair_shard, + parse_room, pit_room, pit_world, room_zone, PitRoomContext, PitRoomFn, BOSS_TIER, }; use mobpit_crawler::{level_floor, KillReceipt, RecordKillInput}; +use mobpit_tiers::{BossDefeatedInput, WEATHER}; use serde::{Deserialize, Serialize}; pub use game_sim::mobpit::{pit_step, PitInput, PitStep}; @@ -319,6 +320,47 @@ pub enum PitEvent { /// Respawn delay (ms). respawn_ms: u32, }, + /// The world above the room (its hood, borough and city): on join and + /// whenever it changes. + #[game_event(id = 7)] + World(WorldTiers), + /// The hood's boss fell here. + #[game_event(id = 8)] + BossDown { + /// Display name of the slayer. + by: String, + /// The hood's tier now. + hood_tier: u32, + }, +} + +/// The tiers above a room, as its players see them. +#[derive(Debug, Clone, Default, PartialEq, GameRecord)] +pub struct WorldTiers { + /// Hood x. + pub hood_x: i32, + /// Hood y. + pub hood_y: i32, + /// Times the hood's boss fell: how much tougher its rooms are. + pub hood_tier: u32, + /// The hood's boss is down. + pub boss_down: bool, + /// Who felled it last. + pub last_by: String, + /// Borough x. + pub borough_x: i32, + /// Borough y. + pub borough_y: i32, + /// Bosses down in the borough (of 9). + pub borough_bosses_down: u32, + /// The borough's weather (index into `WEATHER`). + pub weather: u8, + /// Hoods of the borough whose boss is down (`hx.hy`). + pub hoods_down: Vec, + /// Bosses down across the city. + pub city_bosses_down: u32, + /// The city's banner. + pub banner: String, } /// What services deliver into a room. @@ -337,6 +379,12 @@ pub enum PitMessage { /// Kills. kills: u32, }, + /// The tiers above the room, read from their cells (off the tick). + #[game_event(id = 2)] + World(WorldTiers), + /// The same without the city (not read this round: kept as it was). + #[game_event(id = 3)] + WorldKeepCity(WorldTiers), } /// Mob Pit's client surface (also used by the leaderboard page). @@ -346,6 +394,7 @@ pub fn game_surface() -> GameSurface { .predict::() .pure::() .constant("pit_rules", &pit_rules()) + .constant("weather", &WEATHER.map(String::from).to_vec()) .constant("pit_world", &pit_world()) } @@ -381,14 +430,32 @@ const PLAYER_RESPAWN_TICKS: u64 = 90; /// Mob tier stats: (max hp, speed m/s, damage, radius, telegraph radius). fn tier_stats(tier: u8) -> (i32, f32, i32, f32, f32) { match tier { + 4 => (400, 2.2, 22, 1.2, 3.2), 3 => (100, 3.3, 24, 0.8, 2.6), 2 => (60, 2.4, 16, 0.7, 2.2), _ => (30, 3.0, 9, 0.5, 1.8), } } -/// Mob tiers (1..=3). -pub const TIERS: [u8; 3] = [1, 2, 3]; +/// Mob tiers (1..=3, and 4: a hood's boss). +pub const TIERS: [u8; 4] = [1, 2, 3, BOSS_TIER]; +/// A fallen boss stays down at least this long here (its hood's schedule +/// brings it back; the hood's state is read every few seconds). +const BOSS_RESPAWN_TICKS: u64 = 30 * mobpit_tiers::BOSS_RESPAWN_MS / 1000; + +/// Hit points of a mob of `tier` in a room of hood tier `difficulty` +/// (each boss kill in the hood makes its rooms 25 % tougher, up to +/// `mobpit_tiers::MAX_TIER`). +fn max_hp(tier: u8, difficulty: u32) -> i32 { + let d = difficulty.min(mobpit_tiers::MAX_TIER) as i32; + tier_stats(tier).0 * (4 + d) / 4 +} + +/// Damage of a mob of `tier` at hood tier `difficulty` (+20 % per tier). +fn damage(tier: u8, difficulty: u32) -> i32 { + let d = difficulty.min(mobpit_tiers::MAX_TIER) as i32; + tier_stats(tier).2 * (5 + d) / 5 +} /// Names of the `mob` kind's `state` values, by value. pub const MOB_STATES: [&str; 6] = ["idle", "chase", "windup", "recover", "return", "dead"]; @@ -459,6 +526,8 @@ struct Mob { generation: u32, respawn_at: u64, wander: (f32, f32), + /// The hood tier its hit points are scaled to (for the HP bar). + difficulty: u32, } struct Player { @@ -562,6 +631,8 @@ pub struct MobPit { tick: u64, /// Players whose slot ended here, oldest first (see [`Parked`]). parked: Vec, + /// The tiers above the room (from their cells). + world: WorldTiers, } fn spawn_state(player: PlayerId) -> MoveState { @@ -715,6 +786,7 @@ impl MobPit { let Some(player) = self.players.get(&killer) else { return; }; + let player_name = player.name.clone(); self.kills += 1; // The cell command's own input: what died, never who or how much XP. let input = RecordKillInput { @@ -737,6 +809,35 @@ impl MobPit { }); self.backlog.push_back((killer, fact, x, y)); self.flush_backlog(ctx); + if tier == BOSS_TIER { + self.boss_fell(ctx, generation, player_name); + } + } + + /// The hood's boss fell here: a zone fact into the hood's cell (keyed + /// by the boss's life; the hood raises its tier and brings the boss + /// back on its own schedule) and the news to everyone in the room. + fn boss_fell(&mut self, ctx: &mut Ctx<'_, Self>, generation: u32, by: String) { + let hood = (self.world.hood_x, self.world.hood_y); + let input = BossDefeatedInput { + hood: pair_shard(hood), + room: ctx.zone_id().to_string(), + generation, + by: by.clone(), + }; + if let Ok(fact) = ctx.fact_with_key(&input, &[&"boss", &generation]) { + if ctx.emit_fact(fact).is_err() { + // Backpressure or shutdown: the hood learns of the next fall. + } + } + self.world.boss_down = true; + self.world.hood_tier += 1; + self.world.last_by = by.clone(); + ctx.broadcast(&PitEvent::BossDown { + by, + hood_tier: self.world.hood_tier, + }); + ctx.broadcast(&PitEvent::World(self.world.clone())); } /// Hand queued facts to the host; never blocks. Backpressure keeps the @@ -885,7 +986,12 @@ impl MobPit { if m.hp <= 0 { m.hp = 0; m.state = MobState::Dead; - m.respawn_at = ctx.tick() + RESPAWN_TICKS; + m.respawn_at = ctx.tick() + + if m.tier == BOSS_TIER { + BOSS_RESPAWN_TICKS + } else { + RESPAWN_TICKS + }; m.target = None; self.emit_kill(ctx, player, i); } @@ -903,16 +1009,22 @@ impl MobPit { .map(|(id, p)| (*id, p.state.x, p.state.y)) .collect(); let mut hits: Vec<(PlayerId, i32)> = Vec::new(); + let difficulty = self.world.hood_tier; + let boss_down = self.world.boss_down; for i in 0..self.mobs.len() { - let (max_hp, speed, damage, radius, tele) = tier_stats(self.mobs[i].tier); + let tier = self.mobs[i].tier; + let (_, speed, _, radius, tele) = tier_stats(tier); + let (max_hp, damage) = (max_hp(tier, difficulty), damage(tier, difficulty)); let target_pos = |t: Option| { t.and_then(|t| living.iter().find(|(id, _, _)| *id == t)) .map(|(_, x, y)| (*x, *y)) }; let m = &mut self.mobs[i]; + m.difficulty = difficulty; match m.state { MobState::Dead => { - if tick >= m.respawn_at { + // A boss returns only once its hood says so. + if tick >= m.respawn_at && !(m.tier == BOSS_TIER && boss_down) { m.generation += 1; m.hp = max_hp; (m.x, m.y) = m.home; @@ -976,7 +1088,9 @@ impl MobPit { MobState::Return => { let (hx, hy) = m.home; m.face = step_toward(&mut m.x, &mut m.y, hx, hy, speed * 1.3 * dt); - m.hp = (m.hp + 1).min(max_hp); + if m.tier != BOSS_TIER { + m.hp = (m.hp + 1).min(max_hp); + } if dist((m.x, m.y), m.home) < 0.3 { m.state = MobState::Idle; m.wander = m.home; @@ -1116,6 +1230,7 @@ impl MobPit { view_radius: VIEW_RADIUS, }, ); + ctx.send(player, &PitEvent::World(self.world.clone())); } } @@ -1156,13 +1271,14 @@ impl Zone for MobPit { x: home.0, y: home.1, face: 0.0, - hp: tier_stats(slot.tier).0, + hp: max_hp(slot.tier, 0), state: MobState::Idle, timer: 0, target: None, generation: 0, respawn_at: 0, wander: home, + difficulty: 0, } }) .collect(); @@ -1183,6 +1299,17 @@ impl Zone for MobPit { restored: false, tick: 0, parked: Vec::new(), + world: { + let hood = hood_of(room.room); + let borough = borough_of(hood); + WorldTiers { + hood_x: hood.0, + hood_y: hood.1, + borough_x: borough.0, + borough_y: borough.1, + ..WorldTiers::default() + } + }, } } @@ -1284,6 +1411,43 @@ impl Zone for MobPit { } ctx.send(id, &Self::profile_event(xp, level.max(1), kills, "cell")); } + message @ (PitMessage::World(_) | PitMessage::WorldKeepCity(_)) => { + let (world, keep_city) = match message { + PitMessage::World(world) => (world, false), + PitMessage::WorldKeepCity(world) => (world, true), + PitMessage::Loaded { .. } => unreachable!(), + }; + // Only the hood, borough and city this room belongs to. + // A fall reported here may not have reached the hood yet: + // tiers never go down, and a boss that died here stays down + // until its time is up. + let tick = self.tick; + let fallen_here = self.mobs.iter().any(|m| { + m.tier == BOSS_TIER && m.state == MobState::Dead && tick < m.respawn_at + }); + let world = WorldTiers { + hood_x: self.world.hood_x, + hood_y: self.world.hood_y, + borough_x: self.world.borough_x, + borough_y: self.world.borough_y, + hood_tier: world.hood_tier.max(self.world.hood_tier), + boss_down: world.boss_down || fallen_here, + ..world + }; + let world = if keep_city { + WorldTiers { + city_bosses_down: self.world.city_bosses_down, + banner: self.world.banner.clone(), + ..world + } + } else { + world + }; + if world != self.world { + self.world = world; + ctx.broadcast(&PitEvent::World(self.world.clone())); + } + } } } @@ -1423,7 +1587,7 @@ impl Zone for MobPit { // Back home-side where it stood, calm again. m.x = saved.x; m.y = saved.y; - m.hp = saved.hp.clamp(1, tier_stats(m.tier).0); + m.hp = saved.hp.clamp(1, max_hp(m.tier, mobpit_tiers::MAX_TIER)); m.state = MobState::Return; } } @@ -1461,7 +1625,7 @@ impl Project for Mob { if self.state == MobState::Dead { return None; } - let max_hp = tier_stats(self.tier).0; + let max_hp = max_hp(self.tier, self.difficulty); let tele = if self.state == MobState::Windup { 255 - (u32::from(self.timer) * 255 / u32::from(WINDUP_TICKS)) } else { @@ -1470,7 +1634,7 @@ impl Project for Mob { Some(MobView::project( self, MobViewRest { - hp: (self.hp.max(0) * 100 / max_hp) as u8, + hp: (self.hp.max(0) * 100 / max_hp).min(100) as u8, state: self.state as u8, tele: tele as u8, }, diff --git a/examples/game/server/rooms/tests/rooms.rs b/examples/game/server/rooms/tests/rooms.rs index 0924bb27..a93f5f5f 100644 --- a/examples/game/server/rooms/tests/rooms.rs +++ b/examples/game/server/rooms/tests/rooms.rs @@ -324,3 +324,123 @@ fn positions_survive_hibernation_and_a_crash_snapshot() { old.restore(br#"{"kills":3,"mobs":[]}"#, ""); assert_eq!(old.zone().stats().kills, 3); } + +/// Every event of `out` (decoded). +fn events(out: &[Outbound]) -> Vec { + messages(out) + .into_iter() + .filter_map(|m| match m { + ServerMessage::Event { + type_id, payload, .. + } => PitEvent::decode_event(type_id, &payload).ok().flatten(), + _ => None, + }) + .collect() +} + +#[test] +fn a_boss_falls_to_a_party_its_hood_hears_and_it_stays_down() { + use distributed_game::ZoneInbound; + use game_sim::mobpit::{boss_room, room_zone, BOSS_TIER}; + use mobpit_rooms::{PitMessage, WorldTiers}; + + let clock = ManualClock::new(); + let zone = room_zone(boss_room(DEFAULT_SEED, (0, 0))); + let (facts, _script) = FactQueue::scripted(256); + let mut room = ZoneHost::::new( + &zone, + HostConfig::default(), + Arc::new(clock.clone()), + RoomConfig::for_zone(&zone, DEFAULT_SEED).expect("room"), + ) + .with_facts(facts.with_journal()); + let party: Vec<_> = ["ana", "bob", "cy", "di"] + .iter() + .map(|who| join(&mut room, who)) + .collect(); + let boss = |room: &ZoneHost| *room.zone().mob_positions().last().expect("mobs"); + let mut emitted = Vec::new(); + let mut seen = Vec::new(); + for seq in 1..3000u32 { + let (_, bx, by, alive) = boss(&room); + if !alive { + break; + } + for (client, player) in &party { + let Some(me) = room.zone().player_position(*player) else { + continue; + }; + let (dx, dy) = (bx - me.0, by - me.1); + let d = (dx * dx + dy * dy).sqrt().max(1e-3); + let near = d < 2.6; + let k = if near { 40.0 } else { 127.0 } / d; + let input = PitInput { + mx: (dx * k).round().clamp(-127.0, 127.0) as i8, + my: (dy * k).round().clamp(-127.0, 127.0) as i8, + buttons: if near { PitInput::ATTACK } else { 0 }, + }; + room.push_input( + *client, + seq, + seq, + &distributed_game::GameInput::to_payload(&input), + ); + } + let out = tick(&mut room, &clock); + seen.extend(events(&out)); + emitted.extend(room.take_emitted_facts()); + } + assert!(!boss(&room).3, "the party felled the boss"); + for _ in 0..3 { + seen.extend(events(&tick(&mut room, &clock))); + emitted.extend(room.take_emitted_facts()); + } + let reports: Vec<_> = emitted + .iter() + .filter(|f| f.command() == mobpit_tiers::BOSS_DEFEATED) + .collect(); + assert_eq!(reports.len(), 1, "one report of the fall"); + assert_eq!(reports[0].input()["hood"], "0.0"); + assert_eq!(reports[0].input()["room"], zone.as_str()); + let kills: Vec<_> = emitted + .iter() + .filter(|f| f.command() == mobpit_crawler::RECORD_KILL && f.input()["tier"] == BOSS_TIER) + .collect(); + assert_eq!( + kills.len(), + 1, + "the slayer is credited (XP decided by the cell)" + ); + assert!(seen + .iter() + .any(|e| matches!(e, PitEvent::BossDown { hood_tier: 1, .. }))); + // The hood has not heard yet (tier 0): the room keeps what it knows. + room.deliver(ZoneInbound::Message(PitMessage::World( + WorldTiers::default(), + ))); + let out = tick(&mut room, &clock); + assert!(!events(&out) + .iter() + .any(|e| matches!(e, PitEvent::World(w) if w.hood_tier == 0 || !w.boss_down))); + // Long past its local respawn: still down while the hood says so. + for _ in 0..(30 * 95) { + tick(&mut room, &clock); + } + let down = WorldTiers { + hood_tier: 1, + boss_down: true, + ..WorldTiers::default() + }; + room.deliver(ZoneInbound::Message(PitMessage::World(down.clone()))); + tick(&mut room, &clock); + tick(&mut room, &clock); + assert!(!boss(&room).3, "down while its hood says so"); + // The hood brings it back. + room.deliver(ZoneInbound::Message(PitMessage::World(WorldTiers { + boss_down: false, + ..down + }))); + tick(&mut room, &clock); + tick(&mut room, &clock); + assert!(boss(&room).3, "back when its hood says so"); +} diff --git a/examples/game/server/sim/src/lib.rs b/examples/game/server/sim/src/lib.rs index 38488717..61c08915 100644 --- a/examples/game/server/sim/src/lib.rs +++ b/examples/game/server/sim/src/lib.rs @@ -11,7 +11,6 @@ //! |---|---|---| //! | [`arena::ArenaStep`] | `player` | The Freeze | //! | [`mobpit::PitStep`] | `player` | Mob Pit | -//! | [`ripple::HoodStep`] | `player` | Boss Ripple (hoods) | //! //! The seeded world generation ([`grid`]: noise, biomes, props) feeds Mob //! Pit's rooms; [`mobpit::pit_room`] is a `#[game_pure]` function, so the @@ -24,4 +23,3 @@ pub mod arena; pub mod grid; pub mod mobpit; -pub mod ripple; diff --git a/examples/game/server/sim/src/mobpit.rs b/examples/game/server/sim/src/mobpit.rs index c782584a..a7020717 100644 --- a/examples/game/server/sim/src/mobpit.rs +++ b/examples/game/server/sim/src/mobpit.rs @@ -134,8 +134,19 @@ pub const MAX_ROOM: i32 = 1_000_000; pub const DOOR_HALF: f32 = 2.5; /// Default world seed of the rooms. pub const DEFAULT_SEED: u32 = 0x0B1D_5EED; -/// Mobs a room holds at most. -pub const MAX_MOBS: usize = 12; +/// Mobs a room holds at most (12 slots, plus a boss room's boss). +pub const MAX_MOBS: usize = 13; +/// Mob tier of a hood's boss. +pub const BOSS_TIER: u8 = 4; +/// Where a boss stands (room-local), between the central pillar and the +/// south door. +pub const BOSS_HOME: (f32, f32) = (0.0, -7.0); +/// Rooms per hood side: a hood is 3 × 3 rooms. +pub const HOOD_ROOMS: i32 = 3; +/// Hoods per borough side: a borough is 3 × 3 hoods (9 × 9 rooms). +pub const BOROUGH_HOODS: i32 = 3; +/// Hash salt of the boss rooms. +const SALT_BOSS: u32 = 901; /// Unit directions of the 12 mob slots (angles `(k + 0.5) / 12 · τ`), as /// literals so native and WebAssembly agree bit for bit (no `sin`/`cos`). @@ -198,8 +209,98 @@ pub struct PitRoomLayout { pub biome: u8, /// Danger 1..=3: grows with the distance from the entrance room. pub danger: u8, - /// Mob slots (ids 1..). + /// Mob slots (ids 1..); a boss room's last slot is its boss + /// ([`BOSS_TIER`]). pub mobs: Vec, + /// This is its hood's boss room. + pub boss: bool, + /// Hood x (see [`hood_of`]). + pub hood_x: i32, + /// Hood y. + pub hood_y: i32, + /// Rooms to the nearest boss room (Chebyshev; 0 in a boss room, at most + /// [`BOSS_DISTANCE_MAX`]): fog and darkness thicken toward bosses. + pub boss_distance: u8, +} + +/// The farthest [`PitRoomLayout::boss_distance`] reported. +pub const BOSS_DISTANCE_MAX: u8 = 6; + +/// The hood of room `room` (3 × 3 rooms each, floor division). +pub fn hood_of(room: (i32, i32)) -> (i32, i32) { + (room.0.div_euclid(HOOD_ROOMS), room.1.div_euclid(HOOD_ROOMS)) +} + +/// The borough of hood `hood` (3 × 3 hoods each). +pub fn borough_of(hood: (i32, i32)) -> (i32, i32) { + ( + hood.0.div_euclid(BOROUGH_HOODS), + hood.1.div_euclid(BOROUGH_HOODS), + ) +} + +/// The 9 hoods of borough `borough`. +pub fn borough_hoods(borough: (i32, i32)) -> Vec<(i32, i32)> { + let mut hoods = Vec::with_capacity(9); + for dy in 0..BOROUGH_HOODS { + for dx in 0..BOROUGH_HOODS { + hoods.push(( + borough.0 * BOROUGH_HOODS + dx, + borough.1 * BOROUGH_HOODS + dy, + )); + } + } + hoods +} + +/// `x.y`: the cell shard of a hood or borough. +pub fn pair_shard(at: (i32, i32)) -> String { + format!("{}.{}", at.0, at.1) +} + +/// Parse `x.y` (canonical integers within [`MAX_ROOM`]). +pub fn parse_pair(shard: &str) -> Option<(i32, i32)> { + let (x, y) = shard.split_once('.')?; + let parse = |s: &str| -> Option { + let v: i32 = s.parse().ok()?; + (v.to_string() == s && v.abs() <= MAX_ROOM).then_some(v) + }; + Some((parse(x)?, parse(y)?)) +} + +/// The boss room of hood `hood`: one of its 9 rooms, seeded (never the +/// entrance `pit.0.0`). +pub fn boss_room(seed: u32, hood: (i32, i32)) -> (i32, i32) { + let h = grid::mix(seed, SALT_BOSS, hood.0, hood.1); + let pick = |k: i32| { + ( + hood.0 * HOOD_ROOMS + k % HOOD_ROOMS, + hood.1 * HOOD_ROOMS + k / HOOD_ROOMS, + ) + }; + let room = pick((h % 9) as i32); + if room == (0, 0) { + pick(((h % 9) as i32 + 1) % 9) + } else { + room + } +} + +/// Rooms from `room` to the nearest boss room (Chebyshev, capped at +/// [`BOSS_DISTANCE_MAX`]). +pub fn boss_distance(seed: u32, room: (i32, i32)) -> u8 { + let (hx, hy) = hood_of(room); + let mut best = u32::from(BOSS_DISTANCE_MAX); + for dy in -2..=2 { + for dx in -2..=2 { + let b = boss_room(seed, (hx + dx, hy + dy)); + let d = (b.0 - room.0) + .unsigned_abs() + .max((b.1 - room.1).unsigned_abs()); + best = best.min(d); + } + } + best as u8 } /// The contents of room `(x, y)`: biome from the Endless Grid's noise, @@ -231,10 +332,23 @@ pub fn pit_room(room: &PitRoomContext) -> PitRoomLayout { y: ring * dy, }); } + let hood = hood_of(at); + let boss = boss_room(room.seed, hood) == at; + if boss { + mobs.push(RoomMob { + tier: BOSS_TIER, + x: BOSS_HOME.0, + y: BOSS_HOME.1, + }); + } PitRoomLayout { biome, danger, mobs, + boss, + hood_x: hood.0, + hood_y: hood.1, + boss_distance: boss_distance(room.seed, at), } } @@ -361,6 +475,40 @@ mod tests { .all(|r| (r.biome as usize) < grid::BIOMES.len())); } + #[test] + fn every_hood_has_one_boss_room() { + for hy in -4..4 { + for hx in -4..4 { + let rooms: Vec<(i32, i32)> = (0..9) + .map(|k| (hx * 3 + k % 3, hy * 3 + k / 3)) + .filter(|&(x, y)| { + pit_room(&PitRoomContext { + seed: DEFAULT_SEED, + x, + y, + }) + .boss + }) + .collect(); + assert_eq!(rooms, vec![boss_room(DEFAULT_SEED, (hx, hy))]); + assert_eq!(hood_of(rooms[0]), (hx, hy)); + assert_ne!(rooms[0], (0, 0), "never the entrance"); + } + } + let b = boss_room(DEFAULT_SEED, (0, 0)); + let layout = pit_room(&PitRoomContext { + seed: DEFAULT_SEED, + x: b.0, + y: b.1, + }); + assert_eq!(layout.boss_distance, 0); + assert_eq!(layout.mobs.last().map(|m| m.tier), Some(BOSS_TIER)); + assert_eq!(hood_of((-1, -1)), (-1, -1)); + assert_eq!(borough_of((-1, 5)), (-1, 1)); + assert_eq!(parse_pair(&pair_shard((-3, 7))), Some((-3, 7))); + assert_eq!(borough_hoods((0, 0)).len(), 9); + } + #[test] fn room_ids_and_doorways() { assert_eq!(room_zone((1, -2)), "pit.1.-2"); diff --git a/examples/game/server/src/lib.rs b/examples/game/server/src/lib.rs index e6e3de2a..e4c91db9 100644 --- a/examples/game/server/src/lib.rs +++ b/examples/game/server/src/lib.rs @@ -12,11 +12,9 @@ //! //! See `examples/game/README.md`. -pub mod cluster; pub mod common; pub mod devcelld; pub mod freeze; pub mod gallery; pub mod mobpit; pub mod predict_trace; -pub mod ripple; diff --git a/examples/game/server/src/main.rs b/examples/game/server/src/main.rs index e8204146..11092851 100644 --- a/examples/game/server/src/main.rs +++ b/examples/game/server/src/main.rs @@ -1,12 +1,10 @@ //! `game-demos [--port N] [--dev]` — one subcommand per demo. use clap::{Parser, Subcommand}; -use game_demos::cluster::{run_cluster, ClusterArgs}; use game_demos::common::{run, DemoArgs}; use game_demos::freeze::world::FreezeArgs; use game_demos::gallery::GalleryDemo; use game_demos::mobpit::{self, MobPitArgs}; -use game_demos::ripple::RippleDemo; #[derive(Parser)] #[command( @@ -28,8 +26,6 @@ enum DemoCommand { Gallery(DemoArgs), /// World-version barrier across two server processes, safe-room reloads. Freeze(FreezeArgs), - /// Boss Ripple: realm > city > borough > hood, tier-up events across hosts. - Ripple(ClusterArgs), /// Print native traces of every predicted step over recorded input /// streams (JSON; the WASM exact-match test replays them). #[command(hide = true)] @@ -42,7 +38,6 @@ async fn main() -> std::io::Result<()> { DemoCommand::Mobpit(args) | DemoCommand::Leaderboard(args) => mobpit::run(args).await, DemoCommand::Gallery(args) => run::(args).await, DemoCommand::Freeze(args) => game_demos::freeze::world::run(args).await, - DemoCommand::Ripple(args) => run_cluster::(args).await, DemoCommand::PredictTrace => { println!("{}", game_demos::predict_trace::traces()); Ok(()) diff --git a/examples/game/server/src/mobpit/routes.rs b/examples/game/server/src/mobpit/routes.rs index b356195a..c82cb27a 100644 --- a/examples/game/server/src/mobpit/routes.rs +++ b/examples/game/server/src/mobpit/routes.rs @@ -20,6 +20,9 @@ //! - `POST /dev/mobpit/leaderboard/rebuild` — rebuild both boards from the //! event history of every crawler in the directory. //! - `GET /dev/mobpit/presence?principal=` — the player's presence cell. +//! - `GET /dev/mobpit/tiers?room=pit..` — the 3 × 3 hoods around the +//! room's (boss room, boss down, tier), their boroughs (bosses down, +//! weather) and the city, read from the tier cells (the minimap). //! - `GET /dev/netsim` / `POST /dev/netsim` `{latencyMs, jitterMs, loss}` — //! read / replace the network simulation of **every** room (dev settings //! object on celld, read by each room within a second; applied per @@ -72,6 +75,7 @@ pub fn routes(world: Arc, key: TicketKey) -> Router { .route("/dev/mobpit/leaderboard", get(leaderboard)) .route("/dev/mobpit/leaderboard/rebuild", post(rebuild)) .route("/dev/mobpit/presence", get(presence)) + .route("/dev/mobpit/tiers", get(tiers)) .route("/dev/netsim", get(get_netsim).post(set_netsim)) .with_state(Dev { world, key }) } @@ -229,3 +233,56 @@ async fn set_netsim( .map_err(internal)?; Ok(Json(applied)) } + +async fn tiers(State(dev): State, Query(query): Query>) -> ApiResult { + use game_sim::mobpit::{borough_of, boss_room, hood_of, pair_shard, parse_room}; + let room = query + .get("room") + .and_then(|r| parse_room(r)) + .ok_or_else(|| bad_request("room=pit.. is required"))?; + let seed = std::env::var(mobpit_rooms::SEED_VAR) + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(mobpit_rooms::DEFAULT_SEED); + let cells = dev.world.cells(); + let (hx, hy) = hood_of(room); + let mut hoods = Vec::new(); + let mut boroughs = std::collections::BTreeSet::new(); + for dy in -1..=1 { + for dx in -1..=1 { + let hood = (hx + dx, hy + dy); + let state = cells + .tier(mobpit_tiers::HOOD_KIND, &pair_shard(hood)) + .await + .map_err(internal)?; + let boss = boss_room(seed, hood); + boroughs.insert(borough_of(hood)); + hoods.push(json!({ + "hood": pair_shard(hood), "x": hood.0, "y": hood.1, + "bossRoom": [boss.0, boss.1], + "bossDown": state["boss_down"].as_bool().unwrap_or(false), + "tier": state["tier"].as_u64().unwrap_or(0), + })); + } + } + let mut out = Vec::new(); + for borough in boroughs { + let state = cells + .tier(mobpit_tiers::BOROUGH_KIND, &pair_shard(borough)) + .await + .map_err(internal)?; + let down = state["bosses_down"].as_u64().unwrap_or(0); + out.push(json!({ + "borough": pair_shard(borough), "x": borough.0, "y": borough.1, + "bossesDown": down, + "weather": mobpit_tiers::WEATHER[mobpit_tiers::weather_for(down as u32) as usize], + })); + } + let city = cells + .tier(mobpit_tiers::CITY_KIND, mobpit_tiers::CITY_SHARD) + .await + .map_err(internal)?; + Ok(Json( + json!({ "hoods": hoods, "boroughs": out, "city": city }), + )) +} diff --git a/examples/game/server/src/mobpit/store.rs b/examples/game/server/src/mobpit/store.rs index bc76e838..8fb6040e 100644 --- a/examples/game/server/src/mobpit/store.rs +++ b/examples/game/server/src/mobpit/store.rs @@ -158,6 +158,12 @@ impl CrawlerCells { Ok(body) } + /// A tier cell's state (`kind` = hood / borough / city; `null` before + /// its first event). + pub async fn tier(&self, kind: &str, shard: &str) -> Result { + Ok(self.get(kind, shard).await?["state"].take()) + } + /// The durable presence entry of `principal` (`null` when none). pub async fn presence(&self, principal: &str) -> Result { Ok(self.get(PRESENCE_KIND, principal).await?["current"].take()) diff --git a/examples/game/server/src/predict_trace.rs b/examples/game/server/src/predict_trace.rs index 4a215efb..ad793cbf 100644 --- a/examples/game/server/src/predict_trace.rs +++ b/examples/game/server/src/predict_trace.rs @@ -17,7 +17,6 @@ use distributed_game::pure::{call_bytes, GamePure}; use distributed_game::Wire; use game_sim::arena::{self, ArenaStep, MoveState}; use game_sim::mobpit::PitStep; -use game_sim::ripple::HoodStep; use serde_json::{json, Value}; /// Deterministic xorshift32 (streams must be identical on every run). @@ -211,7 +210,6 @@ pub fn traces() -> Value { "steps": [ trace::(capsule_streams(0)), trace::(capsule_streams(0)), - trace::(capsule_streams(1)), ], }) } @@ -224,7 +222,7 @@ mod tests { fn traces_cover_every_step_and_collide() { let traces = traces(); let steps = traces["steps"].as_array().unwrap(); - assert_eq!(steps.len(), 3); + assert_eq!(steps.len(), 2); for step in steps { for stream in step["streams"].as_array().unwrap() { assert_eq!( diff --git a/examples/game/server/tiers/Cargo.toml b/examples/game/server/tiers/Cargo.toml new file mode 100644 index 00000000..33e0c313 --- /dev/null +++ b/examples/game/server/tiers/Cargo.toml @@ -0,0 +1,23 @@ +# Mob Pit's world tiers: hood, borough and city aggregates (celld cells in +# examples/game/cells), the boss fact a room emits and the saga steps that +# ripple a hood's change up. Portable (wasm32 cells, native tests). +[package] +name = "mobpit_tiers" +version = "0.1.0" +edition = "2021" +publish = false +description = "Mob Pit hood / borough / city tier aggregates (dev demo)" + +[lib] +path = "src/lib.rs" + +[dependencies] +distributed = { path = "../../../.." } +distributed_game = { path = "../../../../distributed_game" } +game_sim = { path = "../sim" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt"] } +distributed_game = { path = "../../../../distributed_game", features = ["cell"] } diff --git a/examples/game/server/tiers/src/lib.rs b/examples/game/server/tiers/src/lib.rs new file mode 100644 index 00000000..ac211abf --- /dev/null +++ b/examples/game/server/tiers/src/lib.rs @@ -0,0 +1,747 @@ +//! **Mob Pit's world tiers.** Rooms group into hoods (3 × 3 rooms), hoods +//! into boroughs (3 × 3 hoods) and boroughs into one city +//! (`game_sim::mobpit::{hood_of, borough_of}`). Each tier is an aggregate +//! in a celld cell of its own (`examples/game/cells`): +//! +//! | cell | aggregate | commands | outward event | +//! |---|---|---|---| +//! | `hood/{hx.hy}` | [`Hood`] | [`BOSS_DEFEATED`] (zone fact of the boss room), [`BOSS_RESPAWN`] (its own schedule) | [`HoodState`] | +//! | `borough/{bx.by}` | [`Borough`] | [`HOOD_CHANGED`] (saga) | [`BoroughState`] | +//! | `city/city` | [`City`] | [`BOROUGH_CHANGED`] (saga) | [`CityState`] | +//! +//! **Up.** A boss falls in its room: the room emits [`BossDefeatedInput`] +//! as a zone fact (exactly once, keyed by the boss's generation) into its +//! hood, which raises its tier and schedules the boss's return +//! ([`BOSS_RESPAWN_MS`], `ctx.schedule`). Each tier change leaves through +//! the cell's outbox as a domain event; the worker's Queue consumer turns +//! a hood's event into [`HOOD_CHANGED`] on its borough and a borough's into +//! [`BOROUGH_CHANGED`] on the city — an explicit saga: each step is an +//! idempotent command (the event id is its command id) and carries the +//! source's stream sequence, so a late or repeated step changes nothing. +//! +//! **Down.** Rooms read their hood, borough and city cells (off the tick, +//! `CelldZone::refresh`) and tell their players: the hood's tier makes the +//! room harder, the borough's weather (storm → rain → clearing → clear as +//! its bosses fall) darkens or lifts the sky, the city carries the banner. +//! +//! Identity: [`BOSS_DEFEATED`] runs only for the room's own zone principal +//! (`zone:pit..`, bound by the room's fact session) and only for the +//! hood that room belongs to; the saga commands only for [`SAGA_PRINCIPAL`], +//! a session the worker builds itself. No input names a reward. + +use std::collections::BTreeMap; +use std::time::{Duration, SystemTime}; + +use distributed::cell_host::AggregateCell; +use distributed::command::{typed_command, PreparedCommand, Succeeded}; +use distributed::microsvc::{ + CausalCommandContext, CausalRouteDependencies, HandlerError, PortableCommand, Routes, +}; +use distributed::schedule::{schedule_guard, Capability, Schedule}; +use distributed::{sourced, Entity}; +use game_sim::mobpit::{borough_of, hood_of, pair_shard, parse_pair, parse_room, BOROUGH_HOODS}; +use serde::{Deserialize, Serialize}; + +/// Cell kind of the hoods: `hood/{hx.hy}`. +pub const HOOD_KIND: &str = "hood"; +/// Cell kind of the boroughs: `borough/{bx.by}`. +pub const BOROUGH_KIND: &str = "borough"; +/// Cell kind of the city: `city/city`. +pub const CITY_KIND: &str = "city"; +/// The city's shard. +pub const CITY_SHARD: &str = "city"; +/// A boss room's fact: its boss fell. +pub const BOSS_DEFEATED: &str = "hood.boss_defeated"; +/// The hood's own schedule: its boss returns. +pub const BOSS_RESPAWN: &str = "hood.boss_respawn"; +/// Saga step: a hood changed (into its borough). +pub const HOOD_CHANGED: &str = "borough.hood_changed"; +/// Saga step: a borough changed (into the city). +pub const BOROUGH_CHANGED: &str = "city.borough_changed"; +/// Outward event of a hood. +pub const HOOD_EVENT: &str = "hood.changed"; +/// Outward event of a borough. +pub const BOROUGH_EVENT: &str = "borough.changed"; +/// Outward event of the city. +pub const CITY_EVENT: &str = "city.changed"; +/// The principal of the saga's sessions (built by the worker's Queue +/// consumer, never from a request). +pub const SAGA_PRINCIPAL: &str = "system:tiers"; +/// A fallen boss returns after this long (cell clock). +pub const BOSS_RESPAWN_MS: u64 = 90_000; +/// Name of the respawn schedule. +const RESPAWN_ALARM: &str = "respawn"; +/// Weather names, by index: the sky of a borough. +pub const WEATHER: [&str; 4] = ["storm", "rain", "clearing", "clear"]; +/// Highest hood tier that still makes a room harder. +pub const MAX_TIER: u32 = 8; + +/// The weather of a borough with `bosses_down` of its 9 bosses down. +pub fn weather_for(bosses_down: u32) -> u8 { + match bosses_down { + 0..=2 => 0, + 3..=5 => 1, + 6..=8 => 2, + _ => 3, + } +} + +/// The principal a zone's own facts carry (`distributed_game`'s zone +/// principal: `zone:{zone}`). +fn zone_principal(zone: &str) -> String { + format!("zone:{zone}") +} + +fn rejected(message: impl std::fmt::Display) -> HandlerError { + HandlerError::Rejected(message.to_string()) +} + +// ---------------------------------------------------------------- hood + +/// One hood: whether its boss is down and how often it fell (its tier). +#[derive(Clone, Debug, Default, Serialize, Deserialize, distributed::Snapshot)] +pub struct Hood { + entity: Entity, + /// The boss is down (until it respawns). + pub boss_down: bool, + /// Times the boss fell: the hood's tier. + pub tier: u32, + /// Display name of the last slayer (cosmetic). + pub last_by: String, + /// When the boss returns (cell clock, ms; 0 while it stands). + pub respawn_at_ms: u64, +} + +/// A hood as its outward events carry it (and rooms read it). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, distributed::DomainState)] +#[domain_state(version = 1)] +pub struct HoodState { + /// `hx.hy`. + pub hood: String, + /// The boss is down. + pub boss_down: bool, + /// Times the boss fell. + pub tier: u32, + /// Last slayer's display name. + pub last_by: String, + /// When the boss returns (ms, 0 while it stands). + pub respawn_at_ms: u64, +} + +impl From<&Hood> for HoodState { + fn from(hood: &Hood) -> Self { + Self { + hood: hood.entity.id().to_string(), + boss_down: hood.boss_down, + tier: hood.tier, + last_by: hood.last_by.clone(), + respawn_at_ms: hood.respawn_at_ms, + } + } +} + +#[sourced(entity, aggregate_type = "Hood", events = "HoodEvent", domain_state = HoodState)] +impl Hood { + #[event("hood.changed", version = 1, domain)] + fn boss_fell(&mut self, hood: String, by: String, respawn_at_ms: u64) { + if self.entity.id().is_empty() { + self.entity.set_id(hood); + } + self.boss_down = true; + self.tier += 1; + self.last_by = by; + self.respawn_at_ms = respawn_at_ms; + } + + #[event("hood.boss_returned", version = 1, domain)] + fn boss_returned(&mut self, hood: String) { + if self.entity.id().is_empty() { + self.entity.set_id(hood); + } + self.boss_down = false; + self.respawn_at_ms = 0; + } +} + +impl Hood { + /// The outward state. + pub fn state(&self) -> HoodState { + HoodState::from(self) + } + + /// Committed stream version. + pub fn version(&self) -> u64 { + self.entity.version() + } +} + +/// [`BOSS_DEFEATED`]: what the boss room reports (which hood and room, +/// which life of the boss, who landed the blow — cosmetic). No reward. +#[derive( + Debug, Clone, Serialize, Deserialize, distributed::CommandInput, distributed_game::FactCommand, +)] +#[fact(command = BOSS_DEFEATED)] +pub struct BossDefeatedInput { + /// `hx.hy`: the hood's shard (the fact route's key). + pub hood: String, + /// The boss room's zone (`pit..`). + pub room: String, + /// The boss's life (its generation in the room). + pub generation: u32, + /// Display name of the slayer (server-sanitized, cosmetic). + pub by: String, +} + +/// [`BOSS_RESPAWN`] input (the hood's own schedule). +#[derive(Debug, Clone, Serialize, Deserialize, distributed::CommandInput)] +pub struct RespawnInput { + /// `hx.hy`. + pub hood: String, +} + +/// A hood command's answer. +#[derive(Debug, Clone, Serialize, Deserialize, distributed::CommandOutput)] +pub struct HoodReceipt { + /// The boss is down after the command. + pub boss_down: bool, + /// The hood's tier after the command. + pub tier: u32, + /// The command changed the hood. + pub changed: bool, +} + +impl HoodReceipt { + fn of(hood: &Hood, changed: bool) -> Self { + Self { + boss_down: hood.boss_down, + tier: hood.tier, + changed, + } + } +} + +fn now_ms( + ctx: &CausalCommandContext<'_, A>, +) -> Result { + ctx.now()? + .duration_since(SystemTime::UNIX_EPOCH) + .map(|now| now.as_millis() as u64) + .map_err(|_| rejected("host clock is before the Unix epoch")) +} + +/// Only the boss room itself may report its boss, and only into its hood. +fn is_boss_room(ctx: &CausalCommandContext<'_, Hood>) -> bool { + ctx.session() + .user_id() + .is_some_and(|p| p.starts_with("zone:pit.")) +} + +async fn handle_boss_defeated( + ctx: &CausalCommandContext<'_, Hood>, + input: BossDefeatedInput, +) -> Result>, HandlerError> { + let room = parse_room(&input.room).ok_or_else(|| rejected("not a room"))?; + if ctx.session().user_id() != Some(zone_principal(&input.room).as_str()) { + return Err(rejected("only the room itself reports its boss")); + } + if pair_shard(hood_of(room)) != input.hood { + return Err(rejected("the room is not in this hood")); + } + let now = now_ms(ctx)?; + let repo = ctx.repo(); + let mut hood = match repo.get(&input.hood).await? { + Some(hood) => hood, + None => repo.create(), + }; + if hood.boss_down { + // Already down (a late report of the same fall): nothing changes. + let receipt = HoodReceipt::of(&hood, false); + return repo.commit(hood)?.succeeded(receipt); + } + let by: String = input.by.chars().take(16).collect(); + let respawn_at_ms = now + BOSS_RESPAWN_MS; + hood.boss_fell(input.hood.clone(), by, respawn_at_ms) + .map_err(rejected)?; + ctx.schedule( + Schedule::at( + RESPAWN_ALARM, + SystemTime::UNIX_EPOCH + Duration::from_millis(respawn_at_ms), + BOSS_RESPAWN, + &RespawnInput { + hood: input.hood.clone(), + }, + ) + .map_err(rejected)?, + )?; + let receipt = HoodReceipt::of(&hood, true); + repo.publish_events().commit(hood)?.succeeded(receipt) +} + +async fn handle_respawn( + ctx: &CausalCommandContext<'_, Hood>, + input: RespawnInput, +) -> Result>, HandlerError> { + let repo = ctx.repo(); + let Some(mut hood) = repo.get(&input.hood).await? else { + return Err(rejected("no hood")); + }; + if !hood.boss_down { + let receipt = HoodReceipt::of(&hood, false); + return repo.commit(hood)?.succeeded(receipt); + } + hood.boss_returned(input.hood).map_err(rejected)?; + let receipt = HoodReceipt::of(&hood, true); + repo.publish_events().commit(hood)?.succeeded(receipt) +} + +// ---------------------------------------------------------------- borough + +/// One hood as its borough last heard of it. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct HoodSeen { + /// The boss is down. + pub boss_down: bool, + /// The hood's tier. + pub tier: u32, + /// Stream sequence of the hood event (later wins). + pub sequence: u64, +} + +/// One borough: its 9 hoods as last heard. +#[derive(Clone, Debug, Default, Serialize, Deserialize, distributed::Snapshot)] +pub struct Borough { + entity: Entity, + /// Hood shard → what was heard last. + pub hoods: BTreeMap, +} + +/// A borough as its outward events carry it (and rooms read it). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, distributed::DomainState)] +#[domain_state(version = 1)] +pub struct BoroughState { + /// `bx.by`. + pub borough: String, + /// Bosses down among its hoods. + pub bosses_down: u32, + /// Index into [`WEATHER`]. + pub weather: u8, + /// Sum of its hoods' tiers. + pub tier: u32, + /// Hoods whose boss is down (`hx.hy`). + pub hoods_down: Vec, +} + +impl From<&Borough> for BoroughState { + fn from(b: &Borough) -> Self { + let hoods_down: Vec = b + .hoods + .iter() + .filter(|(_, h)| h.boss_down) + .map(|(k, _)| k.clone()) + .collect(); + let bosses_down = hoods_down.len() as u32; + Self { + borough: b.entity.id().to_string(), + bosses_down, + weather: weather_for(bosses_down), + tier: b.hoods.values().map(|h| h.tier).sum(), + hoods_down, + } + } +} + +#[sourced( + entity, + aggregate_type = "Borough", + events = "BoroughEvent", + domain_state = BoroughState +)] +impl Borough { + #[event("borough.changed", version = 1, domain)] + fn hood_seen(&mut self, borough: String, hood: String, seen: HoodSeen) { + if self.entity.id().is_empty() { + self.entity.set_id(borough); + } + self.hoods.insert(hood, seen); + } +} + +impl Borough { + /// The outward state. + pub fn state(&self) -> BoroughState { + BoroughState::from(self) + } +} + +/// [`HOOD_CHANGED`] (saga step). +#[derive(Debug, Clone, Serialize, Deserialize, distributed::CommandInput)] +pub struct HoodChangedInput { + /// `bx.by`. + pub borough: String, + /// `hx.hy`. + pub hood: String, + /// The hood's boss is down. + pub boss_down: bool, + /// The hood's tier. + pub tier: u32, + /// The hood event's stream sequence. + pub sequence: u64, +} + +/// A borough or city command's answer. +#[derive(Debug, Clone, Serialize, Deserialize, distributed::CommandOutput)] +pub struct TierReceipt { + /// The command changed the tier. + pub changed: bool, +} + +fn is_saga( + ctx: &CausalCommandContext<'_, A>, +) -> bool { + ctx.session().user_id() == Some(SAGA_PRINCIPAL) +} + +async fn handle_hood_changed( + ctx: &CausalCommandContext<'_, Borough>, + input: HoodChangedInput, +) -> Result>, HandlerError> { + let hood = parse_pair(&input.hood).ok_or_else(|| rejected("not a hood"))?; + if pair_shard(borough_of(hood)) != input.borough { + return Err(rejected("the hood is not in this borough")); + } + let repo = ctx.repo(); + let mut borough = match repo.get(&input.borough).await? { + Some(b) => b, + None => repo.create(), + }; + let stale = borough + .hoods + .get(&input.hood) + .is_some_and(|seen| seen.sequence >= input.sequence); + if stale { + return repo + .commit(borough)? + .succeeded(TierReceipt { changed: false }); + } + borough + .hood_seen( + input.borough, + input.hood, + HoodSeen { + boss_down: input.boss_down, + tier: input.tier, + sequence: input.sequence, + }, + ) + .map_err(rejected)?; + repo.publish_events() + .commit(borough)? + .succeeded(TierReceipt { changed: true }) +} + +// ---------------------------------------------------------------- city + +/// One borough as the city last heard of it. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoroughSeen { + /// Bosses down in it. + pub bosses_down: u32, + /// Stream sequence of the borough event (later wins). + pub sequence: u64, +} + +/// The city: every borough heard from. +#[derive(Clone, Debug, Default, Serialize, Deserialize, distributed::Snapshot)] +pub struct City { + entity: Entity, + /// Borough shard → what was heard last. + pub boroughs: BTreeMap, +} + +/// The city as its outward events carry it (and rooms read it). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, distributed::DomainState)] +#[domain_state(version = 1)] +pub struct CityState { + /// Bosses down anywhere. + pub bosses_down: u32, + /// Boroughs with every boss down. + pub boroughs_cleared: u32, + /// The banner every room shows. + pub banner: String, +} + +impl From<&City> for CityState { + fn from(c: &City) -> Self { + let full = (BOROUGH_HOODS * BOROUGH_HOODS) as u32; + let bosses_down = c.boroughs.values().map(|b| b.bosses_down).sum(); + let boroughs_cleared = c + .boroughs + .values() + .filter(|b| b.bosses_down >= full) + .count() as u32; + let banner = match (bosses_down, boroughs_cleared) { + (0, _) => "The bosses hold the city".to_string(), + (n, 0) => format!( + "{n} boss{} down across the city", + if n == 1 { "" } else { "es" } + ), + (n, c) => format!( + "{c} borough{} liberated · {n} bosses down", + if c == 1 { "" } else { "s" } + ), + }; + Self { + bosses_down, + boroughs_cleared, + banner, + } + } +} + +#[sourced(entity, aggregate_type = "City", events = "CityEvent", domain_state = CityState)] +impl City { + #[event("city.changed", version = 1, domain)] + fn borough_seen(&mut self, city: String, borough: String, seen: BoroughSeen) { + if self.entity.id().is_empty() { + self.entity.set_id(city); + } + self.boroughs.insert(borough, seen); + } +} + +impl City { + /// The outward state. + pub fn state(&self) -> CityState { + CityState::from(self) + } +} + +/// [`BOROUGH_CHANGED`] (saga step). +#[derive(Debug, Clone, Serialize, Deserialize, distributed::CommandInput)] +pub struct BoroughChangedInput { + /// Always [`CITY_SHARD`]. + pub city: String, + /// `bx.by`. + pub borough: String, + /// Bosses down in the borough. + pub bosses_down: u32, + /// The borough event's stream sequence. + pub sequence: u64, +} + +async fn handle_borough_changed( + ctx: &CausalCommandContext<'_, City>, + input: BoroughChangedInput, +) -> Result>, HandlerError> { + if input.city != CITY_SHARD || parse_pair(&input.borough).is_none() { + return Err(rejected("not the city, or not a borough")); + } + let repo = ctx.repo(); + let mut city = match repo.get(&input.city).await? { + Some(c) => c, + None => repo.create(), + }; + let stale = city + .boroughs + .get(&input.borough) + .is_some_and(|seen| seen.sequence >= input.sequence); + if stale { + return repo.commit(city)?.succeeded(TierReceipt { changed: false }); + } + city.borough_seen( + input.city, + input.borough, + BoroughSeen { + bosses_down: input.bosses_down, + sequence: input.sequence, + }, + ) + .map_err(rejected)?; + repo.publish_events() + .commit(city)? + .succeeded(TierReceipt { changed: true }) +} + +// ---------------------------------------------------------------- mounts + +macro_rules! tier_command { + ($name:ident, $agg:ty, $command:expr, $input:ty, $output:ty, $guard:expr, $handler:expr, $($cap:expr),*) => { + /// Mount of the command (see the module docs). + pub struct $name; + impl PortableCommand for $name + where + D: CausalRouteDependencies + Send + Sync + 'static, + { + fn install(self, routes: Routes) -> Routes { + routes + .typed_command(typed_command::<$input, Succeeded<$output>>($command)) + $(.requires($cap))* + .guarded($guard, $handler) + } + } + }; +} + +tier_command!( + BossDefeated, + Hood, + BOSS_DEFEATED, + BossDefeatedInput, + HoodReceipt, + is_boss_room, + handle_boss_defeated, + Capability::Scheduling, + Capability::Clock +); +tier_command!( + BossRespawn, + Hood, + BOSS_RESPAWN, + RespawnInput, + HoodReceipt, + schedule_guard::, + handle_respawn, + Capability::Scheduling, + Capability::Clock +); +tier_command!( + HoodChanged, + Borough, + HOOD_CHANGED, + HoodChangedInput, + TierReceipt, + is_saga::, + handle_hood_changed, +); +tier_command!( + BoroughChanged, + City, + BOROUGH_CHANGED, + BoroughChangedInput, + TierReceipt, + is_saga::, + handle_borough_changed, +); + +/// Mount the hood commands on a `Hood` cell. +pub fn mount_hood(cell: AggregateCell) -> AggregateCell { + cell.mount(BossDefeated).mount(BossRespawn) +} + +/// Mount the borough command on a `Borough` cell. +pub fn mount_borough(cell: AggregateCell) -> AggregateCell { + cell.mount(HoodChanged) +} + +/// Mount the city command on the `City` cell. +pub fn mount_city(cell: AggregateCell) -> AggregateCell { + cell.mount(BoroughChanged) +} + +/// The saga step a hood's outward state asks for: its borough hears of it. +pub fn hood_to_borough(state: &HoodState, sequence: u64) -> Option<(String, HoodChangedInput)> { + let hood = parse_pair(&state.hood)?; + let borough = pair_shard(borough_of(hood)); + Some(( + borough.clone(), + HoodChangedInput { + borough, + hood: state.hood.clone(), + boss_down: state.boss_down, + tier: state.tier, + sequence, + }, + )) +} + +/// The saga step a borough's outward state asks for: the city hears of it. +pub fn borough_to_city(state: &BoroughState, sequence: u64) -> BoroughChangedInput { + BoroughChangedInput { + city: CITY_SHARD.to_string(), + borough: state.borough.clone(), + bosses_down: state.bosses_down, + sequence, + } +} + +/// One step of the tier saga: a command into a tier cell. +#[derive(Debug, Clone, PartialEq)] +pub struct SagaStep { + /// Cell kind ([`BOROUGH_KIND`] or [`CITY_KIND`]). + pub kind: &'static str, + /// Cell shard. + pub shard: String, + /// Command name. + pub command: &'static str, + /// Command input. + pub input: serde_json::Value, +} + +/// The saga step an outward tier event asks for (`None` for any other +/// message, and for the city, the top): a hood's [`HOOD_EVENT`] or +/// `hood.boss_returned` → its borough, a borough's [`BOROUGH_EVENT`] → the +/// city. `payload` is the event's canonical occurrence (the outbox +/// message's bytes, as the Queue delivers them). +pub fn saga_step(name: &str, payload: &[u8]) -> Option { + if !matches!(name, HOOD_EVENT | "hood.boss_returned" | BOROUGH_EVENT) { + return None; + } + let occurrence = distributed::DomainEventOccurrence::from_canonical_bytes(payload).ok()?; + let sequence = occurrence.aggregate_sequence(); + if name == BOROUGH_EVENT { + let state: BoroughState = occurrence.decode_body().ok()?; + return Some(SagaStep { + kind: CITY_KIND, + shard: CITY_SHARD.to_string(), + command: BOROUGH_CHANGED, + input: serde_json::to_value(borough_to_city(&state, sequence)).ok()?, + }); + } + let state: HoodState = occurrence.decode_body().ok()?; + let (borough, input) = hood_to_borough(&state, sequence)?; + Some(SagaStep { + kind: BOROUGH_KIND, + shard: borough, + command: HOOD_CHANGED, + input: serde_json::to_value(input).ok()?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn weather_lifts_as_bosses_fall() { + let names: Vec<&str> = (0..=9).map(|n| WEATHER[weather_for(n) as usize]).collect(); + assert_eq!(names[0], "storm"); + assert_eq!(names[4], "rain"); + assert_eq!(names[7], "clearing"); + assert_eq!(names[9], "clear"); + } + + #[test] + fn saga_steps_follow_the_geometry() { + let state = HoodState { + hood: "4.-1".into(), + boss_down: true, + tier: 2, + last_by: "ana".into(), + respawn_at_ms: 9, + }; + let (borough, step) = hood_to_borough(&state, 7).expect("hood"); + assert_eq!(borough, "1.-1"); + assert_eq!((step.tier, step.sequence, step.boss_down), (2, 7, true)); + let city = borough_to_city( + &BoroughState { + borough: borough.clone(), + bosses_down: 9, + weather: 3, + tier: 9, + hoods_down: vec![], + }, + 3, + ); + assert_eq!((city.city.as_str(), city.bosses_down), (CITY_SHARD, 9)); + } +} diff --git a/examples/game/server/tiers/tests/tiers.rs b/examples/game/server/tiers/tests/tiers.rs new file mode 100644 index 00000000..2b10621e --- /dev/null +++ b/examples/game/server/tiers/tests/tiers.rs @@ -0,0 +1,208 @@ +//! The tier cells on the in-process reference host (a `ManualClock`): a +//! boss room's fact raises its hood, a late or foreign report changes +//! nothing, the respawn schedule brings the boss back, and the saga steps +//! decoded from the outbox ripple the change into the borough and the city +//! (a stale step is ignored). + +use std::time::{Duration, SystemTime}; + +use distributed::cell_host::{ + AggregateCell, CellCommandIdentity, CellDispatchError, CellStreamStore, +}; +use distributed::microsvc::{Session, USER_ID_KEY}; +use distributed::{ManualClock, StreamIdentity}; +use mobpit_tiers::*; +use serde_json::{json, Value}; + +const T0: u64 = 1_700_000_000_000; + +fn cell( + clock: &ManualClock, + aggregate: &str, + shard: &str, +) -> AggregateCell { + let store = CellStreamStore::for_identity(StreamIdentity::new(aggregate, shard).unwrap()) + .with_clock(clock.clone()); + AggregateCell::from_store(store).unwrap() +} + +fn session(principal: &str) -> Session { + let mut session = Session::new(); + session.set(USER_ID_KEY, principal); + session +} + +async fn run( + cell: &AggregateCell, + command: &str, + id: &str, + input: Value, + principal: &str, +) -> Result { + let identity = CellCommandIdentity::new( + "test", + principal, + distributed_game::cell::command_id_for("t", id), + ) + .unwrap(); + cell.dispatch_idempotent(command, &identity, input, session(principal)) + .await + .map(|r| r.payload().clone()) +} + +/// Saga steps of every outbox row of `cell`, in stream order (the outbox +/// itself is not ordered). +fn steps( + cell: &AggregateCell, +) -> Vec { + cell.durable_outbox() + .unwrap() + .into_iter() + .filter_map(|m| saga_step(&m.event_type, &m.payload)) + .collect::>() + .tap_sort() +} + +trait TapSort { + fn tap_sort(self) -> Self; +} + +impl TapSort for Vec { + fn tap_sort(mut self) -> Self { + self.sort_by_key(|s| s.input["sequence"].as_u64()); + self + } +} + +#[tokio::test] +async fn a_boss_kill_ripples_up_and_the_boss_returns() { + let clock = ManualClock::new(SystemTime::UNIX_EPOCH + Duration::from_millis(T0)); + let seed = game_sim::mobpit::DEFAULT_SEED; + let hood_at = (1, 0); + let boss = game_sim::mobpit::boss_room(seed, hood_at); + let room = game_sim::mobpit::room_zone(boss); + let hood_shard = game_sim::mobpit::pair_shard(hood_at); + let hood = mount_hood(cell::(&clock, "Hood", &hood_shard)); + let fact = |by: &str| json!({ "hood": hood_shard, "room": room, "generation": 0, "by": by }); + let zone = format!("zone:{room}"); + + // Only the room itself, only into its own hood. + let foreign = run(&hood, BOSS_DEFEATED, "f1", fact("x"), "zone:pit.9.9").await; + assert!( + matches!(foreign, Err(CellDispatchError::Rejected { .. })), + "{foreign:?}" + ); + let other_room = json!({ "hood": hood_shard, "room": "pit.9.9", "generation": 0, "by": "x" }); + assert!(run(&hood, BOSS_DEFEATED, "f2", other_room, "zone:pit.9.9") + .await + .is_err()); + + let first = run(&hood, BOSS_DEFEATED, "k1", fact("Ana"), &zone) + .await + .unwrap(); + assert_eq!( + ( + first["boss_down"].clone(), + first["tier"].clone(), + first["changed"].clone() + ), + (json!(true), json!(1), json!(true)) + ); + // A late report of the same fall changes nothing. + let again = run(&hood, BOSS_DEFEATED, "k2", fact("Bob"), &zone) + .await + .unwrap(); + assert_eq!(again["changed"], false); + let state = hood.load().await.unwrap().unwrap(); + assert_eq!( + (state.boss_down, state.tier, state.last_by.as_str()), + (true, 1, "Ana") + ); + + // The saga: hood → borough → city. + let up = steps(&hood); + assert_eq!(up.len(), 1, "{up:?}"); + assert_eq!((up[0].kind, up[0].command), (BOROUGH_KIND, HOOD_CHANGED)); + let borough = mount_borough(cell::(&clock, "Borough", &up[0].shard)); + run( + &borough, + HOOD_CHANGED, + "s1", + up[0].input.clone(), + SAGA_PRINCIPAL, + ) + .await + .unwrap(); + // Only the saga may run it; a repeated step changes nothing. + assert!(run( + &borough, + HOOD_CHANGED, + "s2", + up[0].input.clone(), + "zone:pit.0.0" + ) + .await + .is_err()); + let repeat = run( + &borough, + HOOD_CHANGED, + "s3", + up[0].input.clone(), + SAGA_PRINCIPAL, + ) + .await + .unwrap(); + assert_eq!(repeat["changed"], false); + let b = borough.load().await.unwrap().unwrap().state(); + assert_eq!((b.bosses_down, b.weather, b.tier), (1, 0, 1)); + let up = steps(&borough); + assert_eq!(up.len(), 1); + assert_eq!((up[0].kind, up[0].shard.as_str()), (CITY_KIND, CITY_SHARD)); + let city = mount_city(cell::(&clock, "City", CITY_SHARD)); + run( + &city, + BOROUGH_CHANGED, + "c1", + up[0].input.clone(), + SAGA_PRINCIPAL, + ) + .await + .unwrap(); + let c = city.load().await.unwrap().unwrap().state(); + assert_eq!(c.bosses_down, 1); + assert!(c.banner.contains("1 boss down"), "{}", c.banner); + + // The boss returns on the hood's own schedule; the borough hears of it. + clock.advance(Duration::from_millis(BOSS_RESPAWN_MS - 1)); + assert!(hood.fire_due_schedules().await.unwrap().fired.is_empty()); + clock.advance(Duration::from_millis(1)); + assert_eq!(hood.fire_due_schedules().await.unwrap().fired.len(), 1); + let state = hood.load().await.unwrap().unwrap(); + assert_eq!((state.boss_down, state.tier), (false, 1), "the tier stays"); + let up = steps(&hood); + assert_eq!(up.len(), 2); + run( + &borough, + HOOD_CHANGED, + "s4", + up[1].input.clone(), + SAGA_PRINCIPAL, + ) + .await + .unwrap(); + // The first step delivered late is stale. + let late = run( + &borough, + HOOD_CHANGED, + "s5", + up[0].input.clone(), + SAGA_PRINCIPAL, + ) + .await + .unwrap(); + assert_eq!(late["changed"], false); + assert_eq!( + borough.load().await.unwrap().unwrap().state().bosses_down, + 0 + ); +} diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts b/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts index b1acf0eb..0e3c20f8 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts @@ -8,7 +8,8 @@ export const PIT_RULES: PitRules = { tiers: [ { tier: 1, radius: 0.5, tele: 1.7999999523162842 }, { tier: 2, radius: 0.699999988079071, tele: 2.200000047683716 }, - { tier: 3, radius: 0.800000011920929, tele: 2.5999999046325684 } + { tier: 3, radius: 0.800000011920929, tele: 2.5999999046325684 }, + { tier: 4, radius: 1.2000000476837158, tele: 3.200000047683716 } ], states: ['idle', 'chase', 'windup', 'recover', 'return', 'dead'], attackRange: 2.200000047683716, @@ -40,3 +41,6 @@ export const PIT_WORLD: PitWorld = { maxRoom: 1000000, biomes: ['highlands', 'steppe', 'forest', 'meadow'] }; + +/** `weather`, as the client decodes it. */ +export const WEATHER: readonly string[] = ['storm', 'rain', 'clearing', 'clear']; diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/events.ts b/examples/game/sveltekit/src/lib/generated/mobpit/events.ts index 4a305927..3363c5b7 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/events.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/events.ts @@ -46,6 +46,10 @@ export interface PitRoomLayout { readonly biome: number; readonly danger: number; readonly mobs: readonly RoomMob[]; + readonly boss: boolean; + readonly hoodX: number; + readonly hoodY: number; + readonly bossDistance: number; } /** Record `PitRules` (`#[derive(GameRecord)]`). */ @@ -94,6 +98,40 @@ const PROFILE_TYPE: WireType = { ] }; +/** Record `WorldTiers` (`#[derive(GameRecord)]`). */ +export interface WorldTiers { + readonly hoodX: number; + readonly hoodY: number; + readonly hoodTier: number; + readonly bossDown: boolean; + readonly lastBy: string; + readonly boroughX: number; + readonly boroughY: number; + readonly boroughBossesDown: number; + readonly weather: number; + readonly hoodsDown: readonly string[]; + readonly cityBossesDown: number; + readonly banner: string; +} + +const WORLD_TIERS_TYPE: WireType = { + type: 'record', + fields: [ + ['hoodX', { type: 'i32' }], + ['hoodY', { type: 'i32' }], + ['hoodTier', { type: 'u32' }], + ['bossDown', { type: 'bool' }], + ['lastBy', { type: 'short_str' }], + ['boroughX', { type: 'i32' }], + ['boroughY', { type: 'i32' }], + ['boroughBossesDown', { type: 'u32' }], + ['weather', { type: 'u8' }], + ['hoodsDown', { type: 'list', item: { type: 'short_str' } }], + ['cityBossesDown', { type: 'u32' }], + ['banner', { type: 'short_str' }], + ] +}; + /** Event `hello` (id 1). */ export interface HelloEvent { readonly entity: number; @@ -187,6 +225,22 @@ const DIED_LAYOUT: PayloadLayout = [ ['respawnMs', { type: 'u32' }], ]; +/** Event `world` (id 7). */ +export type WorldEvent = WorldTiers; + +const WORLD_PAYLOAD: WireType = WORLD_TIERS_TYPE; + +/** Event `boss_down` (id 8). */ +export interface BossDownEvent { + readonly by: string; + readonly hoodTier: number; +} + +const BOSS_DOWN_LAYOUT: PayloadLayout = [ + ['by', { type: 'short_str' }], + ['hoodTier', { type: 'u32' }], +]; + /** Message `loaded` (id 1). */ export interface LoadedMessage { readonly principal: string; @@ -202,6 +256,16 @@ const MESSAGE_LOADED_LAYOUT: PayloadLayout = [ ['kills', { type: 'u32' }], ]; +/** Message `world` (id 2). */ +export type WorldMessage = WorldTiers; + +const MESSAGE_WORLD_PAYLOAD: WireType = WORLD_TIERS_TYPE; + +/** Message `world_keep_city` (id 3). */ +export type WorldKeepCityMessage = WorldTiers; + +const MESSAGE_WORLD_KEEP_CITY_PAYLOAD: WireType = WORLD_TIERS_TYPE; + /** Event type ids by name. */ export const EVENT = { hello: 1, @@ -209,6 +273,8 @@ export const EVENT = { kill: 3, saved: 4, died: 6, + world: 7, + bossDown: 8, } as const; /** Event payload types by name. */ @@ -218,6 +284,8 @@ export interface Events { kill: KillEvent; saved: SavedEvent; died: DiedEvent; + world: WorldEvent; + bossDown: BossDownEvent; } /** Codecs of every event. */ @@ -252,6 +320,18 @@ export const EVENTS: GameEventCodecs = { encode: (value) => encodePayload(DIED_LAYOUT, value), decode: (payload) => decodePayload(DIED_LAYOUT, payload) as unknown as DiedEvent }, + world: { + id: 7, + name: 'world', + encode: (value) => encodeValue(WORLD_PAYLOAD, value), + decode: (payload) => decodeValue(WORLD_PAYLOAD, payload) as WorldEvent + }, + bossDown: { + id: 8, + name: 'boss_down', + encode: (value) => encodePayload(BOSS_DOWN_LAYOUT, value), + decode: (payload) => decodePayload(BOSS_DOWN_LAYOUT, payload) as unknown as BossDownEvent + }, }; /** A decoded event, discriminated by `name`. */ @@ -274,11 +354,15 @@ export function onEvent( /** Zone inbox message type ids by name. */ export const MESSAGE = { loaded: 1, + world: 2, + worldKeepCity: 3, } as const; /** Zone inbox message payload types by name. */ export interface Messages { loaded: LoadedMessage; + world: WorldMessage; + worldKeepCity: WorldKeepCityMessage; } /** Codecs of every zone inbox message. */ @@ -289,6 +373,18 @@ export const MESSAGES: GameEventCodecs = { encode: (value) => encodePayload(MESSAGE_LOADED_LAYOUT, value), decode: (payload) => decodePayload(MESSAGE_LOADED_LAYOUT, payload) as unknown as LoadedMessage }, + world: { + id: 2, + name: 'world', + encode: (value) => encodeValue(MESSAGE_WORLD_PAYLOAD, value), + decode: (payload) => decodeValue(MESSAGE_WORLD_PAYLOAD, payload) as WorldMessage + }, + worldKeepCity: { + id: 3, + name: 'world_keep_city', + encode: (value) => encodeValue(MESSAGE_WORLD_KEEP_CITY_PAYLOAD, value), + decode: (payload) => decodeValue(MESSAGE_WORLD_KEEP_CITY_PAYLOAD, payload) as WorldKeepCityMessage + }, }; /** Encode a zone inbox message: `typeId` and `payload` of a `/admin/zone-message` body (base64url the payload). */ diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/pures.ts b/examples/game/sveltekit/src/lib/generated/mobpit/pures.ts index e2d25d5c..d9002aa6 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/pures.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/pures.ts @@ -29,6 +29,10 @@ const PIT_ROOM_LAYOUT_TYPE: WireType = { ['biome', { type: 'u8' }], ['danger', { type: 'u8' }], ['mobs', { type: 'list', item: ROOM_MOB_TYPE }], + ['boss', { type: 'bool' }], + ['hoodX', { type: 'i32' }], + ['hoodY', { type: 'i32' }], + ['bossDistance', { type: 'u8' }], ] }; diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/surface.json b/examples/game/sveltekit/src/lib/generated/mobpit/surface.json index 8fa40e36..bb630ce6 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/surface.json +++ b/examples/game/sveltekit/src/lib/generated/mobpit/surface.json @@ -33,6 +33,11 @@ "radius": 0.800000011920929, "tele": 2.5999999046325684, "tier": 3 + }, + { + "radius": 1.2000000476837158, + "tele": 3.200000047683716, + "tier": 4 } ] } @@ -107,6 +112,21 @@ "player_radius": 0.5, "room_size": 40.0 } + }, + { + "name": "weather", + "type": { + "item": { + "type": "short_str" + }, + "type": "list" + }, + "value": [ + "storm", + "rain", + "clearing", + "clear" + ] } ], "events": [ @@ -339,6 +359,38 @@ ], "kind": "fields" } + }, + { + "id": 7, + "name": "world", + "payload": { + "kind": "value", + "type": { + "name": "WorldTiers", + "type": "record" + } + } + }, + { + "id": 8, + "name": "boss_down", + "payload": { + "fields": [ + { + "name": "by", + "type": { + "type": "short_str" + } + }, + { + "name": "hood_tier", + "type": { + "type": "u32" + } + } + ], + "kind": "fields" + } } ], "input": { @@ -709,6 +761,28 @@ ], "kind": "fields" } + }, + { + "id": 2, + "name": "world", + "payload": { + "kind": "value", + "type": { + "name": "WorldTiers", + "type": "record" + } + } + }, + { + "id": 3, + "name": "world_keep_city", + "payload": { + "kind": "value", + "type": { + "name": "WorldTiers", + "type": "record" + } + } } ], "name": "mobpit", @@ -869,6 +943,30 @@ }, "type": "list" } + }, + { + "name": "boss", + "type": { + "type": "bool" + } + }, + { + "name": "hood_x", + "type": { + "type": "i32" + } + }, + { + "name": "hood_y", + "type": { + "type": "i32" + } + }, + { + "name": "boss_distance", + "type": { + "type": "u8" + } } ], "name": "PitRoomLayout" @@ -1056,6 +1154,86 @@ } ], "name": "RoomMob" + }, + { + "fields": [ + { + "name": "hood_x", + "type": { + "type": "i32" + } + }, + { + "name": "hood_y", + "type": { + "type": "i32" + } + }, + { + "name": "hood_tier", + "type": { + "type": "u32" + } + }, + { + "name": "boss_down", + "type": { + "type": "bool" + } + }, + { + "name": "last_by", + "type": { + "type": "short_str" + } + }, + { + "name": "borough_x", + "type": { + "type": "i32" + } + }, + { + "name": "borough_y", + "type": { + "type": "i32" + } + }, + { + "name": "borough_bosses_down", + "type": { + "type": "u32" + } + }, + { + "name": "weather", + "type": { + "type": "u8" + } + }, + { + "name": "hoods_down", + "type": { + "item": { + "type": "short_str" + }, + "type": "list" + } + }, + { + "name": "city_bosses_down", + "type": { + "type": "u32" + } + }, + { + "name": "banner", + "type": { + "type": "short_str" + } + } + ], + "name": "WorldTiers" } ], "schemaVersion": 3 diff --git a/examples/game/sveltekit/src/lib/pit/loop.ts b/examples/game/sveltekit/src/lib/pit/loop.ts index fba0f136..930b9ff1 100644 --- a/examples/game/sveltekit/src/lib/pit/loop.ts +++ b/examples/game/sveltekit/src/lib/pit/loop.ts @@ -24,10 +24,11 @@ import { pillarFields, playerFields, type HelloEvent, - type PitInput + type PitInput, + type WorldTiers } from '$lib/generated/mobpit/index.ts'; -const TIER_COLOR: Record = { 1: '#9ad16b', 2: '#b48cf2', 3: '#ff6b6b' }; +const TIER_COLOR: Record = { 1: '#9ad16b', 2: '#b48cf2', 3: '#ff6b6b', 4: '#ff2d55' }; const TIER_RADIUS = Object.fromEntries(PIT_RULES.tiers.map((t) => [t.tier, t])); const PLAYER_RADIUS = 0.45; const DOOR_HALF = PIT_WORLD.doorHalf; @@ -86,8 +87,13 @@ export type PitLoopOptions = Readonly<{ autoHunt: () => boolean; /** Draw the server's authoritative copy of you (the ghost). */ ghost?: () => boolean; + /** The tiers above the room (borough weather darkens the view). */ + world?: () => WorldTiers | undefined; }>; +/** Darkness of the view (0..1): the borough's weather, thicker near a boss room. */ +const WEATHER_DARK = [0.55, 0.4, 0.22, 0.05]; + /** Start drawing on `canvas`; returns the stop function. */ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions): () => void { const { client } = options; @@ -301,6 +307,15 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) } } canvas.dataset.nextDoorMobs = String(nextDoor); + // Weather and darkness: the borough's sky, thicker toward bosses. + if (here !== undefined && pures.loaded()) { + const world = options.world?.(); + const near = Math.max(0, 1 - pures.pitRoom({ seed, x: here[0], y: here[1] }).bossDistance / 6); + const dark = Math.min(0.85, (WEATHER_DARK[world?.weather ?? 0] ?? 0.55) * (1 + 0.6 * near)); + ctx.fillStyle = `rgba(8,10,20,${dark.toFixed(3)})`; + ctx.fillRect(0, 0, width, height); + canvas.dataset.dark = dark.toFixed(3); + } frame = requestAnimationFrame(draw); }); diff --git a/examples/game/sveltekit/src/routes/pit/+page.svelte b/examples/game/sveltekit/src/routes/pit/+page.svelte index 4ef928cf..687b97bf 100644 --- a/examples/game/sveltekit/src/routes/pit/+page.svelte +++ b/examples/game/sveltekit/src/routes/pit/+page.svelte @@ -1,7 +1,7 @@ + +
      + {#each rows as row (row.borough)} +
    • + {row.event} in borough {row.borough} · {Math.max(0, Math.round((row.event_until_ms - now) / 1000))} s left +
    • + {:else} +
    • no world event right now
    • + {/each} +
    + + diff --git a/examples/game/sveltekit/src/routes/pit/+page.svelte b/examples/game/sveltekit/src/routes/pit/+page.svelte index e2089007..411995dd 100644 --- a/examples/game/sveltekit/src/routes/pit/+page.svelte +++ b/examples/game/sveltekit/src/routes/pit/+page.svelte @@ -5,6 +5,7 @@ import Leaderboard from '$lib/pit/Leaderboard.svelte'; import MyStats from '$lib/pit/MyStats.svelte'; import Rooms from '$lib/pit/Rooms.svelte'; + import Events from '$lib/pit/Events.svelte'; import { neighbourRooms, pitHandoffFrame, startPitLoop } from '$lib/pit/loop'; let { data } = $props(); @@ -179,10 +180,18 @@
    hood {world.hoodX},{world.hoodY} · tier {world.hoodTier} · boss {world.bossDown ? `down (${world.lastBy})` : 'standing'}
    borough {world.boroughX},{world.boroughY} · {WEATHER[world.weather]} · {world.boroughBossesDown}/9 bosses down
    {world.banner}
    + {#if world.event} +
    {world.event} in this borough
    + {/if} {:else}
    …
    {/if} +
    +

    World events

    + + +

    Network

    @@ -239,4 +248,5 @@ h2 { margin: 0 0 0.5rem; font-size: 1rem; } .error { color: #ff6b6b; } .saved { color: #9ad16b; } + .event { color: #e0a8ff; font-weight: 600; } diff --git a/examples/game/sveltekit/tests/pit.test.mjs b/examples/game/sveltekit/tests/pit.test.mjs index a13d2894..35da92a2 100644 --- a/examples/game/sveltekit/tests/pit.test.mjs +++ b/examples/game/sveltekit/tests/pit.test.mjs @@ -435,6 +435,19 @@ test('sign in, join, move, kill, walk through rooms on celld, live read models f { timeout: 15_000 } ); + // World events: the entrance's borough runs its own (scheduled by its + // cell); the boroughs read model lists the running one live, and the + // room shows it to its players. + // (One with time to spare, so the room's view is checked while it runs.) + await page.waitForFunction( + () => Number(/(\d+) s left/.exec(document.querySelector('[data-testid=events] li[data-borough="0.0"]')?.textContent ?? '')?.[1] ?? 0) >= 8, + null, + { timeout: 90_000 } + ); + assert.equal(await page.getAttribute('[data-testid=events]', 'data-live'), 'active'); + console.error(`world event (read model, @live): ${await page.textContent('[data-testid=events] li[data-borough="0.0"]')}`); + await page.waitForSelector('[data-testid=world-event]', { timeout: 10_000 }); + // SSR after a reload: the settled numbers are in the server HTML. const html = await page.evaluate(() => fetch('/pit', { headers: { accept: 'text/html' } }).then((r) => r.text())); assert.match(html, new RegExp(`data-testid="kills"[^>]*>${kills}<`)); From a84dd27d612dd5ae74fd6df2c64ed79acbade8b1 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 21 Oct 2026 02:32:58 -0500 Subject: [PATCH 10/21] fix(game): a celld zone wakes without waiting for dev settings A wake is often a doorway into a sleeping zone with the player's inputs waiting; reading the dev network simulation now runs off the join path (it applies within a second anyway). Refs [[tasks/gr-p44-mobpit-world]] --- distributed_game/README.md | 3 +- distributed_game/src/celld_zone/object.rs | 5 +- examples/game/client/gallery/index.html | 13 - examples/game/client/src/gallery/client.ts | 242 ------- examples/game/client/src/gallery/gallery.css | 47 -- examples/game/client/src/gallery/main.ts | 266 -------- .../client/src/generated/gallery/client.ts | 49 -- .../client/src/generated/gallery/constants.ts | 19 - .../client/src/generated/gallery/events.ts | 108 --- .../client/src/generated/gallery/index.ts | 8 - .../client/src/generated/gallery/input.ts | 61 -- .../client/src/generated/gallery/schema.ts | 96 --- .../client/src/generated/gallery/surface.json | 393 ----------- .../client/tests/gallery-headless.test.mjs | 114 ---- examples/game/server/src/gallery/mod.rs | 636 ------------------ examples/game/server/tests/gallery_smoke.rs | 151 ----- 16 files changed, 5 insertions(+), 2206 deletions(-) delete mode 100644 examples/game/client/gallery/index.html delete mode 100644 examples/game/client/src/gallery/client.ts delete mode 100644 examples/game/client/src/gallery/gallery.css delete mode 100644 examples/game/client/src/gallery/main.ts delete mode 100644 examples/game/client/src/generated/gallery/client.ts delete mode 100644 examples/game/client/src/generated/gallery/constants.ts delete mode 100644 examples/game/client/src/generated/gallery/events.ts delete mode 100644 examples/game/client/src/generated/gallery/index.ts delete mode 100644 examples/game/client/src/generated/gallery/input.ts delete mode 100644 examples/game/client/src/generated/gallery/schema.ts delete mode 100644 examples/game/client/src/generated/gallery/surface.json delete mode 100644 examples/game/client/tests/gallery-headless.test.mjs delete mode 100644 examples/game/server/src/gallery/mod.rs delete mode 100644 examples/game/server/tests/gallery_smoke.rs diff --git a/distributed_game/README.md b/distributed_game/README.md index 1ccb7af4..686b9192 100644 --- a/distributed_game/README.md +++ b/distributed_game/README.md @@ -101,9 +101,8 @@ their mechanic moves in): | Demo | Run | Shows | |---|---|---| -| Mob Pit | `examples/game/run.sh mobpit` (`--fleet` for several celld nodes) | rooms as celld objects joined by doorways, sleeping when empty and restored on return; combat whose rewards are saved exactly once without stalling; a network panel (simulated latency / jitter / loss on every room, prediction on/off with a server ghost, the interest ring); the rooms next door seen through passive watchers; hoods › boroughs › city as tier cells (boss kills ripple up through a saga, weather lifts as bosses fall) | +| Mob Pit | `examples/game/run.sh mobpit` (`--fleet` for several celld nodes) | rooms as celld objects joined by doorways, sleeping when empty and restored on return; combat whose rewards are saved exactly once without stalling; a network panel (simulated latency / jitter / loss on every room, prediction on/off with a server ghost, the interest ring); the rooms next door seen through passive watchers; hoods › boroughs › city as tier cells (boss kills ripple up through a saga, weather lifts as bosses fall); world events scheduled by boroughs; jumping; a bow with lag-compensated hits and first person | | Leaderboard | `run.sh leaderboard` | a live ranking fed from saved kills, rebuilt from history | -| Shooting Gallery | `run.sh gallery` | lag compensation on/off under latency | | The Freeze | `run.sh freeze` | pausing every zone on two servers, updating content, resuming; safe-room reloads | Then open `http://localhost:5173//`. Each page explains what to try. diff --git a/distributed_game/src/celld_zone/object.rs b/distributed_game/src/celld_zone/object.rs index 69e2dbf2..8ddf8c0f 100644 --- a/distributed_game/src/celld_zone/object.rs +++ b/distributed_game/src/celld_zone/object.rs @@ -1097,7 +1097,10 @@ impl ZoneObject { } self.store_status().await; self.find_node(); - self.read_dev_settings().await; + // Off the join path: a wake (often a doorway into a sleeping zone, + // with the player's inputs waiting) does not wait for dev settings. + let this = self.handle(); + wasm_bindgen_futures::spawn_local(async move { this.read_dev_settings().await }); self.refresh(); Ok(()) } diff --git a/examples/game/client/gallery/index.html b/examples/game/client/gallery/index.html deleted file mode 100644 index fd203adb..00000000 --- a/examples/game/client/gallery/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Shooting Gallery Demo - - - -
    - - - diff --git a/examples/game/client/src/gallery/client.ts b/examples/game/client/src/gallery/client.ts deleted file mode 100644 index 240229eb..00000000 --- a/examples/game/client/src/gallery/client.ts +++ /dev/null @@ -1,242 +0,0 @@ -/** - * Shooting Gallery networking without rendering (Node-testable). - * - * - Targets are interpolated like any remote entity with the SDK's default - * adaptive delay (`AdaptiveDelay`): the clock estimates the server's - * *present*, so a fixed 100 ms delay would ask for states that have not - * arrived yet once one-way latency exceeds it; the delay follows - * `rtt / 2 + 2 patch intervals + 2 × jitter`, slewed slowly. - * - `shoot(dir)` ray-tests the rendered targets locally (client-predicted - * hit marker) and sends one shot input carrying the exact view time: - * `client_tick` (input header) plus `delay = tickTime(client_tick) - - * renderTime` in the payload, so the server's `hit_test_shot` rewinds to - * precisely the instant that was on screen (clamped to `maxRewindMs`). - * - The server answers every shot with a verdict event; `stats` compares - * predicted and confirmed hits. - */ -import { - type AdaptiveDelay, - type AdaptiveDelayOptions, - type GameClient, - type GameSocketFactory, - type GameZoneView, - type RenderedEntity -} from '@hops-ops/distributed/game'; - -import * as gallery from '../generated/gallery/index.ts'; -import { zoneUrl, type DevApi } from '../shared/dev-api.ts'; -import { latest } from '../shared/latest.ts'; - -export const DEFAULT_ZONE = 'gallery-1'; - -export type Vec3 = [number, number, number]; - -export type GalleryHello = gallery.HelloEvent; -export type ShotVerdict = gallery.ShotEvent; - -export type ShotRecord = { - id: number; - dir: Vec3; - compensated: boolean; - /** Target the client saw the ray hit (undefined = predicted miss). */ - predicted: number | undefined; - /** Where the ray met the predicted target (for markers). */ - point: Vec3 | undefined; - delayMs: number; - sentAt: number; - verdict?: ShotVerdict; -}; - -export type GalleryStats = { - shots: number; - predictedHits: number; - confirmedHits: number; - /** Shots where client and server agree (hit/miss and target). */ - agreed: number; - /** Verdicts received. */ - resolved: number; - clamped: number; - /** Mean rewind age of compensated verdicts (ms). */ - meanAgeMs: number; - lastAgeMs: number; -}; - -function vec3(v: readonly number[]): Vec3 { - return [v[0] ?? 0, v[1] ?? 0, v[2] ?? 0]; -} - -/** Entry distance of a ray (unit `dir`) into a sphere, if hit in front. */ -export function raySphere(origin: Vec3, dir: Vec3, center: Vec3, radius: number): number | undefined { - const oc: Vec3 = [origin[0] - center[0], origin[1] - center[1], origin[2] - center[2]]; - const c = oc[0] ** 2 + oc[1] ** 2 + oc[2] ** 2 - radius * radius; - if (c <= 0) return 0; - const b = oc[0] * dir[0] + oc[1] * dir[1] + oc[2] * dir[2]; - const h = b * b - c; - if (h < 0 || b > 0) return undefined; - return -b - Math.sqrt(h); -} - -export function targetPosition(entity: Pick): Vec3 { - const { x, y, z } = gallery.targetFields(entity); - return [x, y, z]; -} - -export type GalleryClient = Readonly<{ - client: GameClient; - /** The SDK's adaptive interpolation delay of `zone` (default: the authority). */ - delay(zone?: GameZoneView): AdaptiveDelay; - hello(): GalleryHello | undefined; - ready: Promise; - stats: GalleryStats; - shots: Map; - /** Targets as rendered now. */ - targets(): RenderedEntity[]; - /** Nearest rendered target on the ray from the eye along `dir`. */ - predict(dir: Vec3): { target: number | undefined; point: Vec3 | undefined }; - /** Fire along `dir` (game coordinates, z up) from the eye; undefined if not sent. */ - shoot(dir: Vec3, options?: { compensated?: boolean }): ShotRecord | undefined; - /** Resolves with the verdict of shot `id`. */ - verdict(id: number): Promise; - onVerdict(listener: (shot: ShotRecord) => void): () => void; -}>; - -export type GalleryClientOptions = Readonly<{ - api: DevApi; - base?: string; - socket?: GameSocketFactory; - adaptiveDelay?: AdaptiveDelayOptions; -}>; - -export function createGalleryClient(options: GalleryClientOptions): GalleryClient { - const typed = gallery.createGalleryClient({ - url: (zone) => zoneUrl(zone, options.base), - ticket: options.api.ticketSource(), - socket: options.socket, - pingIntervalMs: 250, - adaptiveDelay: options.adaptiveDelay - }); - const { client } = typed; - const delay = (zone: GameZoneView | undefined = client.authority): AdaptiveDelay => { - if (zone === undefined) throw new Error('not joined'); - return client.adaptiveDelay(zone)!; - }; - const hello = latest((h) => typed.on('hello', h)); - - const stats: GalleryStats = { - shots: 0, - predictedHits: 0, - confirmedHits: 0, - agreed: 0, - resolved: 0, - clamped: 0, - meanAgeMs: 0, - lastAgeMs: 0 - }; - const shots = new Map(); - const waiters = new Map void>(); - const listeners = new Set<(shot: ShotRecord) => void>(); - let next = 0; - let compensatedVerdicts = 0; - - typed.on('shot', (verdict) => { - const shot = shots.get(verdict.shot); - if (shot === undefined || shot.verdict !== undefined) return; - shot.verdict = verdict; - stats.resolved += 1; - if (verdict.hit !== undefined) stats.confirmedHits += 1; - if (verdict.hit === shot.predicted) stats.agreed += 1; - if (verdict.clamped) stats.clamped += 1; - if (verdict.compensated) { - compensatedVerdicts += 1; - stats.meanAgeMs += (verdict.ageMs - stats.meanAgeMs) / compensatedVerdicts; - stats.lastAgeMs = verdict.ageMs; - } - waiters.get(verdict.shot)?.(verdict); - waiters.delete(verdict.shot); - for (const listener of listeners) listener(shot); - }); - - const targets = () => client.render().filter((e) => gallery.isTarget(e) && !e.stale); - - function predict(dir: Vec3): { target: number | undefined; point: Vec3 | undefined } { - const eye = hello.get()?.eye; - if (eye === undefined) return { target: undefined, point: undefined }; - const len = Math.hypot(dir[0], dir[1], dir[2]) || 1; - const unit: Vec3 = [dir[0] / len, dir[1] / len, dir[2] / len]; - let target: number | undefined; - let point: Vec3 | undefined; - let best = Infinity; - for (const entity of targets()) { - const d = raySphere(vec3(eye), unit, targetPosition(entity), gallery.targetFields(entity).r); - if (d !== undefined && d < best && d <= (hello.get()?.range ?? 80)) { - best = d; - target = entity.id; - point = [eye[0] + unit[0] * d, eye[1] + unit[1] * d, eye[2] + unit[2] * d]; - } - } - return { target, point }; - } - - function shoot(dir: Vec3, opts: { compensated?: boolean } = {}): ShotRecord | undefined { - const zone = client.authority; - const eye = hello.get()?.eye; - if (zone === undefined || eye === undefined || !zone.connection.canSendInput()) return undefined; - const len = Math.hypot(dir[0], dir[1], dir[2]) || 1; - const unit: Vec3 = [dir[0] / len, dir[1] / len, dir[2] / len]; - // Exact view time: the delay is measured from client_tick's time to - // the instant render() samples, so the server rewinds to what is drawn. - const context = client.inputContext(zone)!; - const renderTime = zone.connection.serverNow() - context.interpolationDelayMs; - const delayMs = Math.max(0, zone.connection.tickTime(context.clientTick) - renderTime); - const { target: predicted, point } = predict(unit); - const compensated = opts.compensated ?? true; - next = (next + 1) & 0xffff; - const id = next; - const flags = gallery.BUTTON.fire | (compensated ? 0 : gallery.BUTTON.noComp); - const payload = gallery.encodeInput({ - flags, - shot: id, - delayMs: Math.min(0xffff, Math.round(delayMs)), - dx: unit[0], - dy: unit[1], - dz: unit[2] - }); - const seq = zone.connection.sendInput(payload, context.clientTick); - if (seq === undefined) return undefined; - const record: ShotRecord = { - id, - dir: unit, - compensated, - predicted, - point, - delayMs, - sentAt: zone.connection.now() - }; - shots.set(id, record); - if (shots.size > 512) shots.delete(shots.keys().next().value!); - stats.shots += 1; - if (predicted !== undefined) stats.predictedHits += 1; - return record; - } - - return { - client, - delay, - hello: hello.get, - ready: hello.ready, - stats, - shots, - targets, - predict, - shoot, - verdict(id) { - const known = shots.get(id)?.verdict; - if (known !== undefined) return Promise.resolve(known); - return new Promise((resolve) => waiters.set(id, resolve)); - }, - onVerdict(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - } - }; -} diff --git a/examples/game/client/src/gallery/gallery.css b/examples/game/client/src/gallery/gallery.css deleted file mode 100644 index cafaa140..00000000 --- a/examples/game/client/src/gallery/gallery.css +++ /dev/null @@ -1,47 +0,0 @@ -.crosshair { - position: fixed; - left: 50%; - top: 50%; - width: 18px; - height: 18px; - margin: -9px 0 0 -9px; - pointer-events: none; - border: 2px solid rgba(255, 255, 255, 0.75); - border-radius: 50%; - box-sizing: border-box; -} -.crosshair.kick { - animation: kick 120ms ease-out; -} -@keyframes kick { - from { - transform: scale(1.6); - border-color: #ffd84a; - } -} -.shot-feed { - position: fixed; - right: 12px; - bottom: 12px; - width: 340px; - margin: 0; - padding: 8px 12px 8px 28px; - border-radius: 8px; - background: var(--panel); - pointer-events: none; -} -.shot-feed:empty { - display: none; -} -.shot-feed .ok { - color: #59e08c; -} -.shot-feed .bad { - color: #ff5d5d; -} -@media (max-width: 640px) { - .shot-feed { - width: calc(100% - 24px); - box-sizing: border-box; - } -} diff --git a/examples/game/client/src/gallery/main.ts b/examples/game/client/src/gallery/main.ts deleted file mode 100644 index ac33338a..00000000 --- a/examples/game/client/src/gallery/main.ts +++ /dev/null @@ -1,266 +0,0 @@ -/** - * Shooting Gallery page: first-person view from the player's station over - * `createGalleryClient`. - * - * - Click (or Space / gamepad A through the crosshair) fires a hitscan ray. - * A yellow marker shows the client-predicted hit immediately; when the - * server's verdict arrives it turns green (confirmed) or red (rejected). - * - C toggles lag compensation (sent per shot). Raise the NetSim latency to - * 150–250 ms: with compensation the confirmed hits keep up with the - * predicted ones, without it most "hits" are rejected. - * - G shows wireframe ghosts at the newest received target state (what the - * server had a one-way latency ago), next to the interpolated targets you - * aim at. - */ -import { Color3 } from '@babylonjs/core/Maths/math.color'; -import { Matrix, Vector3 } from '@babylonjs/core/Maths/math.vector'; -import type { Mesh } from '@babylonjs/core/Meshes/mesh'; -import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder'; -import type { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial'; -import type { Scene } from '@babylonjs/core/scene'; - -import { DevApi } from '../shared/dev-api.ts'; -import { Hud } from '../shared/hud.ts'; -import { InputSampler } from '../shared/input.ts'; -import { createStage, material, toWorld } from '../shared/scene.ts'; -import { GALLERY_COURSE, isShooter, isTarget, shooterFields, targetFields } from '../generated/gallery/index.ts'; -import { createGalleryClient, DEFAULT_ZONE, targetPosition, type ShotRecord, type Vec3 } from './client.ts'; -import '@babylonjs/core/Culling/ray'; // side effect: scene.createPickingRay -import './gallery.css'; - -const params = new URLSearchParams(location.search); -const zone = params.get('zone') ?? DEFAULT_ZONE; -const name = params.get('name') ?? undefined; - -const api = new DevApi({ name: () => name }); -const gallery = createGalleryClient({ api }); -const { client } = gallery; - -const canvas = document.getElementById('stage') as HTMLCanvasElement; -const stage = createStage(canvas, { half: 20, background: '#0b0f14' }); -const { scene, camera } = stage; - -// First person: an ArcRotateCamera orbiting a point 5 cm away is a look-around camera. -camera.lowerRadiusLimit = 0.05; -camera.upperRadiusLimit = 0.05; -camera.radius = 0.05; -camera.lowerBetaLimit = 1.2; -camera.upperBetaLimit = 1.85; -camera.beta = Math.PI / 2 - 0.05; -camera.alpha = -Math.PI / 2; -camera.angularSensibilityX = 2500; -camera.angularSensibilityY = 2500; -camera.fov = 0.9; -camera.minZ = 0.02; - -let compensation = true; -let ghostsOn = true; - -const hud = new Hud({ - title: 'Shooting Gallery', - hint: 'Click a target (or Space/A through the crosshair). Drag to look. C: lag compensation, G: server ghosts.', - client, - devApi: api -}); -hud.toggle('Lag compensation', true, (on) => (compensation = on), 'c'); -hud.toggle('Ghosts at newest server state', true, (on) => (ghostsOn = on), 'g'); - -const crosshair = document.createElement('div'); -crosshair.className = 'crosshair'; -document.body.append(crosshair); -const feed = document.createElement('ol'); -feed.className = 'shot-feed'; -document.body.append(feed); - -// ---- meshes ---- -const mats = { - target: material(scene, 'target', '#ff8a3d'), - flash: material(scene, 'flash', '#ffffff'), - ghost: material(scene, 'ghost', '#9ad1ff', 0.35), - station: material(scene, 'station', '#3a4556'), - me: material(scene, 'me-station', '#6cc4ff'), - rail: material(scene, 'rail', '#2b3442'), - predicted: material(scene, 'predicted', '#ffd84a'), - confirmed: material(scene, 'confirmed', '#59e08c'), - rejected: material(scene, 'rejected', '#ff5d5d') -}; -mats.ghost.wireframe = true; -for (const m of [mats.predicted, mats.confirmed, mats.rejected, mats.flash]) m.emissiveColor = m.diffuseColor.clone(); - -// Rails / circles of the server course (generated `GALLERY_COURSE`) and the -// firing line (drawn just in front of the stations). -for (const { y, x0, x1 } of GALLERY_COURSE.rails) { - const rail = MeshBuilder.CreateBox('rail', { width: x1 - x0, height: 0.05, depth: 0.1 }, scene); - rail.position = toWorld((x0 + x1) / 2, y, 0.03); - rail.material = mats.rail; -} -for (const { cx, cy, r } of GALLERY_COURSE.circles) { - const ring = MeshBuilder.CreateTorus('loop', { diameter: r * 2, thickness: 0.08, tessellation: 48 }, scene); - ring.position = toWorld(cx, cy, 0.04); - ring.material = mats.rail; -} -const line = MeshBuilder.CreateBox('firing-line', { width: 18, height: 0.04, depth: 0.15 }, scene); -line.position = toWorld(0, GALLERY_COURSE.firingLine + 0.8, 0.02); -line.material = mats.station; - -type TargetVisual = { mesh: Mesh; ghost: Mesh; flash: number; flashUntil: number }; -const targets = new Map(); -const shooters = new Map(); - -function targetVisual(key: string, r: number): TargetVisual { - let v = targets.get(key); - if (v === undefined) { - const mesh = MeshBuilder.CreateSphere(key, { diameter: r * 2, segments: 16 }, scene); - mesh.material = mats.target; - const ghost = MeshBuilder.CreateSphere(`${key}-ghost`, { diameter: r * 2, segments: 8 }, scene); - ghost.material = mats.ghost; - v = { mesh, ghost, flash: -1, flashUntil: 0 }; - targets.set(key, v); - } - return v; -} - -// ---- shot markers ---- -type Marker = { mesh: Mesh; until: number }; -const markers: Marker[] = []; - -function marker(at: Vec3, mat: StandardMaterial, size: number, ttl: number): Mesh { - const mesh = MeshBuilder.CreateSphere('marker', { diameter: size, segments: 6 }, scene); - mesh.position = toWorld(at[0], at[1], at[2]); - mesh.material = mat; - markers.push({ mesh, until: performance.now() + ttl }); - return mesh; -} - -const pending = new Map(); - -function fire(dir: Vec3): void { - const shot = gallery.shoot(dir, { compensated: compensation }); - if (shot === undefined) return; - if (shot.point !== undefined) pending.set(shot.id, marker(shot.point, mats.predicted, 0.35, 4000)); - crosshair.classList.remove('kick'); - void crosshair.offsetWidth; - crosshair.classList.add('kick'); -} - -gallery.onVerdict((shot: ShotRecord) => { - const v = shot.verdict!; - const predictedMarker = pending.get(shot.id); - pending.delete(shot.id); - const confirmed = v.hit !== undefined; - if (predictedMarker !== undefined) predictedMarker.material = confirmed && v.hit === shot.predicted ? mats.confirmed : mats.rejected; - else if (confirmed) { - const target = gallery.targets().find((t) => t.id === v.hit); - if (target !== undefined) marker(targetPosition(target), mats.confirmed, 0.3, 1500); - } - const li = document.createElement('li'); - const verdict = confirmed ? (shot.predicted === v.hit ? 'CONFIRMED' : `HIT #${v.hit} (unexpected)`) : shot.predicted === undefined ? 'miss' : 'REJECTED'; - li.className = confirmed ? 'ok' : shot.predicted === undefined ? '' : 'bad'; - li.textContent = `#${shot.id} ${verdict} · ${v.compensated ? `rewind ${v.ageMs.toFixed(0)} ms${v.clamped ? ` (clamped from ${v.requestedAgeMs.toFixed(0)})` : ''}` : 'no compensation'}`; - feed.prepend(li); - while (feed.children.length > 8) feed.lastChild?.remove(); -}); - -// ---- input ---- -const sampler = new InputSampler(); -let down: { x: number; y: number } | undefined; -canvas.addEventListener('pointerdown', (e) => (down = { x: e.clientX, y: e.clientY })); -canvas.addEventListener('pointerup', (e) => { - if (down === undefined) return; - const moved = Math.hypot(e.clientX - down.x, e.clientY - down.y); - down = undefined; - if (moved > 6) return; // a drag looks around - const rect = canvas.getBoundingClientRect(); - const scale = stage.engine.getHardwareScalingLevel(); - const ray = scene.createPickingRay((e.clientX - rect.left) / scale, (e.clientY - rect.top) / scale, Matrix.Identity(), camera); - fire([ray.direction.x, ray.direction.z, ray.direction.y]); -}); -let actionWasDown = false; - -// ---- frame ---- -stage.onFrame((now) => { - const hello = gallery.hello(); - if (hello !== undefined) { - const eye = toWorld(hello.eye[0], hello.eye[1], hello.eye[2]); - if (!camera.target.equals(eye)) camera.setTarget(eye, false, false, true); // keep alpha/beta/radius - } - const s = sampler.sample(); - if (s.action && !actionWasDown) { - const forward = camera.getForwardRay(1).direction; - fire([forward.x, forward.z, forward.y]); - } - actionWasDown = s.action; - - const rendered = client.render(); - const authority = client.authority; - const seen = new Set(); - for (const entity of rendered) { - seen.add(entity.key); - if (isTarget(entity)) { - const { r, flash } = targetFields(entity); - const v = targetVisual(entity.key, r); - const p = targetPosition(entity); - v.mesh.position.copyFrom(toWorld(p[0], p[1], p[2])); - if (v.flash >= 0 && flash !== v.flash) v.flashUntil = now + 150; - v.flash = flash; - v.mesh.material = now < v.flashUntil ? mats.flash : mats.target; - const newest = authority?.replica.get(entity.id); - v.ghost.setEnabled(ghostsOn && newest !== undefined); - if (newest !== undefined) { - const g = targetPosition(newest); - v.ghost.position.copyFrom(toWorld(g[0], g[1], g[2])); - } - } else if (isShooter(entity)) { - const shooter = shooterFields(entity); - let mesh = shooters.get(entity.key); - const mine = entity.id === hello?.entity; - if (mesh === undefined) { - mesh = MeshBuilder.CreateCylinder(entity.key, { diameter: 0.9, height: 0.15, tessellation: 24 }, scene); - mesh.material = mine ? mats.me : mats.station; - shooters.set(entity.key, mesh); - } - mesh.position.copyFrom(toWorld(shooter.x, shooter.y, 0.08)); - if (!mine) stage.labels.set(entity.key, mesh, `${shooter.name} · ${shooter.hits}/${shooter.shots}`, 1.9); - } - } - for (const [key, v] of targets) { - if (seen.has(key)) continue; - v.mesh.dispose(); - v.ghost.dispose(); - targets.delete(key); - } - for (const [key, mesh] of shooters) { - if (seen.has(key)) continue; - mesh.dispose(); - stage.labels.delete(key); - shooters.delete(key); - } - for (let i = markers.length - 1; i >= 0; i -= 1) { - if (now > markers[i].until) { - markers[i].mesh.dispose(); - markers.splice(i, 1); - } - } - - hud.frame({ entities: rendered.length }); - const st = gallery.stats; - const pct = (n: number, d: number) => (d === 0 ? '—' : `${Math.round((100 * n) / d)}%`); - hud.row('Interp delay', authority === undefined ? '—' : `${gallery.delay(authority).value.toFixed(0)} ms (target ${gallery.delay(authority).target(authority).toFixed(0)})`); - hud.row('Compensation', compensation ? `ON (max ${hello?.maxRewindMs ?? '—'} ms)` : 'OFF'); - hud.row('Shots', String(st.shots)); - hud.row('Predicted hits', `${st.predictedHits} (${pct(st.predictedHits, st.shots)})`); - hud.row('Confirmed hits', `${st.confirmedHits} (${pct(st.confirmedHits, st.resolved)})`); - hud.row('Client = server', `${st.agreed}/${st.resolved} (${pct(st.agreed, st.resolved)})`); - hud.row('Rewind age', st.lastAgeMs === 0 ? '—' : `last ${st.lastAgeMs.toFixed(0)} · mean ${st.meanAgeMs.toFixed(0)} ms`); - hud.row('Clamped shots', String(st.clamped)); -}); - -declare global { - interface Window { - /** Debug handle (console). */ - gallery?: unknown; - } -} -window.gallery = { gallery, client, api, scene: scene as Scene, Vector3, Color3 }; - -client.connect(zone).catch((error: unknown) => hud.row('Status', `failed: ${String(error)}`)); diff --git a/examples/game/client/src/generated/gallery/client.ts b/examples/game/client/src/generated/gallery/client.ts deleted file mode 100644 index d14738b4..00000000 --- a/examples/game/client/src/generated/gallery/client.ts +++ /dev/null @@ -1,49 +0,0 @@ -// @generated by `distributed game-client` from game surface `gallery` (schema v3, protocol v1). -// Typed client factory. -// Do not edit: change the Rust definitions and regenerate. -import { GameClient, mergeCorrections, type GameClientOptions, type GamePredictionOptions } from '@hops-ops/distributed/game'; - -import { EVENTS, type Events } from './events.ts'; -import { encodeInput, type ShotInput } from './input.ts'; -import { CORRECTIONS } from './schema.ts'; - -/** Prediction options; `encodeInput` and the correction defaults are filled in. */ -export type GalleryPredictionOptions = Omit, 'encodeInput'> & - Partial, 'encodeInput'>>; - -export type GalleryClientOptions = Omit, 'prediction'> & - Readonly<{ prediction?: GalleryPredictionOptions }>; - -export type GalleryClient = Readonly<{ - client: GameClient; - /** Typed event subscription (every zone); returns the unsubscribe. */ - on(name: K, handler: (value: Events[K], zoneId: string) => void): () => void; - /** Send one input: predicted when prediction is configured, raw otherwise. */ - send(input: ShotInput): number | undefined; -}>; - -/** A `GameClient` for the `gallery` surface with its input encoder and correction defaults. */ -export function createGalleryClient(options: GalleryClientOptions): GalleryClient { - const prediction = options.prediction; - const client = new GameClient({ - ...options, - prediction: - prediction === undefined - ? undefined - : { - ...prediction, - encodeInput: prediction.encodeInput ?? ((input) => encodeInput(input)), - corrections: mergeCorrections(CORRECTIONS, prediction.corrections) - } - }); - return { - client, - on: (name, handler) => - client.on('event', (zoneId, event) => { - const codec = EVENTS[name]; - if (event.typeId === codec.id) handler(codec.decode(event.payload), zoneId); - }), - send: (input) => - prediction === undefined ? client.sendRawInput(encodeInput(input)) : client.sendInput(input) - }; -} diff --git a/examples/game/client/src/generated/gallery/constants.ts b/examples/game/client/src/generated/gallery/constants.ts deleted file mode 100644 index 8e41724f..00000000 --- a/examples/game/client/src/generated/gallery/constants.ts +++ /dev/null @@ -1,19 +0,0 @@ -// @generated by `distributed game-client` from game surface `gallery` (schema v3, protocol v1). -// Constants: static data defined once in Rust (`GameSurface::constant`). -// Do not edit: change the Rust definitions and regenerate. -import type { GalleryCourse } from './events.ts'; - -/** `gallery_course`, as the client decodes it. */ -export const GALLERY_COURSE: GalleryCourse = { - rails: [ - { y: 2, x0: -12, x1: 12 }, - { y: 6, x0: -14, x1: 14 }, - { y: 16, x0: -16, x1: 16 }, - { y: -2, x0: -10, x1: 10 } - ], - circles: [ - { cx: -7, cy: 11, r: 4 }, - { cx: 7, cy: 11, r: 3 } - ], - firingLine: -14 -}; diff --git a/examples/game/client/src/generated/gallery/events.ts b/examples/game/client/src/generated/gallery/events.ts deleted file mode 100644 index 2ccc1992..00000000 --- a/examples/game/client/src/generated/gallery/events.ts +++ /dev/null @@ -1,108 +0,0 @@ -// @generated by `distributed game-client` from game surface `gallery` (schema v3, protocol v1). -// Events and records: payload interfaces, ids, codecs, typed subscriptions. -// Do not edit: change the Rust definitions and regenerate. -import { decodeGameEvent, decodePayload, encodePayload, onGameEvent, type DecodedGameEvent, type GameEventCodecs, type GameEventSource, type PayloadLayout } from '@hops-ops/distributed/game'; - -/** Record `CircleTrack` (`#[derive(GameRecord)]`). */ -export interface CircleTrack { - readonly cx: number; - readonly cy: number; - readonly r: number; -} - -/** Record `RailTrack` (`#[derive(GameRecord)]`). */ -export interface RailTrack { - readonly y: number; - readonly x0: number; - readonly x1: number; -} - -/** Record `GalleryCourse` (`#[derive(GameRecord)]`). */ -export interface GalleryCourse { - readonly rails: readonly RailTrack[]; - readonly circles: readonly CircleTrack[]; - readonly firingLine: number; -} - -/** Event `hello` (id 1). */ -export interface HelloEvent { - readonly entity: number; - readonly eye: readonly number[]; - readonly maxRewindMs: number; - readonly range: number; - readonly half: number; -} - -const HELLO_LAYOUT: PayloadLayout = [ - ['entity', { type: 'u32' }], - ['eye', { type: 'list', item: { type: 'f32' } }], - ['maxRewindMs', { type: 'u32' }], - ['range', { type: 'f32' }], - ['half', { type: 'f32' }], -]; - -/** Event `shot` (id 2). */ -export interface ShotEvent { - readonly shot: number; - readonly hit: number | undefined; - readonly compensated: boolean; - readonly ageMs: number; - readonly requestedAgeMs: number; - readonly clamped: boolean; - readonly tick: number; -} - -const SHOT_LAYOUT: PayloadLayout = [ - ['shot', { type: 'u16' }], - ['hit', { type: 'optional', item: { type: 'u32' } }], - ['compensated', { type: 'bool' }], - ['ageMs', { type: 'f64' }], - ['requestedAgeMs', { type: 'f64' }], - ['clamped', { type: 'bool' }], - ['tick', { type: 'u32' }], -]; - -/** Event type ids by name. */ -export const EVENT = { - hello: 1, - shot: 2, -} as const; - -/** Event payload types by name. */ -export interface Events { - hello: HelloEvent; - shot: ShotEvent; -} - -/** Codecs of every event. */ -export const EVENTS: GameEventCodecs = { - hello: { - id: 1, - name: 'hello', - encode: (value) => encodePayload(HELLO_LAYOUT, value), - decode: (payload) => decodePayload(HELLO_LAYOUT, payload) as unknown as HelloEvent - }, - shot: { - id: 2, - name: 'shot', - encode: (value) => encodePayload(SHOT_LAYOUT, value), - decode: (payload) => decodePayload(SHOT_LAYOUT, payload) as unknown as ShotEvent - }, -}; - -/** A decoded event, discriminated by `name`. */ -export type AnyEvent = DecodedGameEvent; - -/** Decode an Event message (`undefined` for ids this surface does not declare). */ -export function decodeEvent(event: { readonly typeId: number; readonly payload: Uint8Array }): AnyEvent | undefined { - return decodeGameEvent(EVENTS, event); -} - -/** Subscribe to one typed event on every zone of `client`; returns the unsubscribe. */ -export function onEvent( - client: GameEventSource, - name: K, - handler: (value: Events[K], zoneId: string) => void -): () => void { - return onGameEvent(client, EVENTS, name, handler); -} diff --git a/examples/game/client/src/generated/gallery/index.ts b/examples/game/client/src/generated/gallery/index.ts deleted file mode 100644 index 8f76cd21..00000000 --- a/examples/game/client/src/generated/gallery/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @generated by `distributed game-client` from game surface `gallery` (schema v3, protocol v1). -// Everything generated for this surface. -// Do not edit: change the Rust definitions and regenerate. -export * from './client.ts'; -export * from './constants.ts'; -export * from './events.ts'; -export * from './input.ts'; -export * from './schema.ts'; diff --git a/examples/game/client/src/generated/gallery/input.ts b/examples/game/client/src/generated/gallery/input.ts deleted file mode 100644 index c6e3dccc..00000000 --- a/examples/game/client/src/generated/gallery/input.ts +++ /dev/null @@ -1,61 +0,0 @@ -// @generated by `distributed game-client` from game surface `gallery` (schema v3, protocol v1). -// Input frame: interface, button bits, encoder, decoder, sanitize. -// Do not edit: change the Rust definitions and regenerate. -import { decodePayload, encodePayload, sanitizePayload, type InputRule, type PayloadLayout } from '@hops-ops/distributed/game'; - -/** One `ShotInput` input frame (`#[derive(GameInput)]`). */ -export interface ShotInput { - readonly flags: number; - readonly shot: number; - readonly delayMs: number; - readonly dx: number; - readonly dy: number; - readonly dz: number; -} - -export type Input = ShotInput; - -/** Bits of each bit-flag field (`#[buttons(..)]`), by field. */ -export const BITS = { - flags: { - fire: 1, - noComp: 2, - }, -} as const; - -/** Bits of `flags`, the only bit-flag field. */ -export const BUTTON = BITS.flags; - -/** Wire layout, in field order. */ -export const INPUT_LAYOUT: PayloadLayout = [ - ['flags', { type: 'u8' }], - ['shot', { type: 'u16' }], - ['delayMs', { type: 'u16' }], - ['dx', { type: 'quant', min: -1, max: 0.999969482421875, bits: 16 }], - ['dy', { type: 'quant', min: -1, max: 0.999969482421875, bits: 16 }], - ['dz', { type: 'quant', min: -1, max: 0.999969482421875, bits: 16 }], -]; - -const INPUT_RULES: readonly InputRule[] = [ - { buttons: 3 }, - undefined, - undefined, - undefined, - undefined, - undefined, -]; - -/** Payload bytes of one input frame. */ -export function encodeInput(input: ShotInput): Uint8Array { - return encodePayload(INPUT_LAYOUT, input); -} - -/** Decode one input frame (no sanitizing). */ -export function decodeInput(payload: Uint8Array): ShotInput { - return decodePayload(INPUT_LAYOUT, payload) as unknown as ShotInput; -} - -/** What the zone simulates for `input` (`#[range]` clamps, `#[buttons]` masks, wire precision). */ -export function sanitizeInput(input: ShotInput): ShotInput { - return sanitizePayload(INPUT_LAYOUT, INPUT_RULES, input)!; -} diff --git a/examples/game/client/src/generated/gallery/schema.ts b/examples/game/client/src/generated/gallery/schema.ts deleted file mode 100644 index 1bf71ceb..00000000 --- a/examples/game/client/src/generated/gallery/schema.ts +++ /dev/null @@ -1,96 +0,0 @@ -// @generated by `distributed game-client` from game surface `gallery` (schema v3, protocol v1). -// Replicated kinds: ids, typed fields, wire types, correction defaults. -// Do not edit: change the Rust definitions and regenerate. -import { fieldNumber, fieldString, type CorrectionPolicies, type EntityFields, type WireType } from '@hops-ops/distributed/game'; - -/** Replicated kind ids by name. */ -export const KIND = { - shooter: 1, - target: 2, -} as const; - -export type KindName = keyof typeof KIND; - -/** `shooter` (kind 1, priority 0.5). */ -export interface ShooterFields { - /** `quant [-20, 19.999847] / 18 bits` */ - readonly x: number; - /** `quant [-20, 19.999847] / 18 bits` */ - readonly y: number; - /** `short_str`, priority 0.2 */ - readonly name: string; - /** `u32` */ - readonly shots: number; - /** `u32` */ - readonly hits: number; -} - -/** True for a `shooter` entity. */ -export function isShooter(entity: { readonly kind: number }): boolean { - return entity.kind === 1; -} - -/** Typed fields of a `shooter` entity (rendered or replica). */ -export function shooterFields(entity: { readonly fields: EntityFields }): ShooterFields { - const f = entity.fields; - return { - x: fieldNumber(f, 'x'), - y: fieldNumber(f, 'y'), - name: fieldString(f, 'name'), - shots: fieldNumber(f, 'shots'), - hits: fieldNumber(f, 'hits'), - }; -} - -/** `target` (kind 2, priority 2). */ -export interface TargetFields { - /** `quant [-20, 19.999847] / 18 bits` */ - readonly x: number; - /** `quant [-20, 19.999847] / 18 bits` */ - readonly y: number; - /** `quant [0, 4] / 12 bits` */ - readonly z: number; - /** `f32` */ - readonly r: number; - /** `u8` */ - readonly flash: number; -} - -/** True for a `target` entity. */ -export function isTarget(entity: { readonly kind: number }): boolean { - return entity.kind === 2; -} - -/** Typed fields of a `target` entity (rendered or replica). */ -export function targetFields(entity: { readonly fields: EntityFields }): TargetFields { - const f = entity.fields; - return { - x: fieldNumber(f, 'x'), - y: fieldNumber(f, 'y'), - z: fieldNumber(f, 'z'), - r: fieldNumber(f, 'r'), - flash: fieldNumber(f, 'flash'), - }; -} - -/** Wire type of every replicated field, by kind and wire field name. */ -export const FIELD_TYPES = { - shooter: { - x: { type: 'quant', min: -20, max: 19.999847412109375, bits: 18 }, - y: { type: 'quant', min: -20, max: 19.999847412109375, bits: 18 }, - name: { type: 'short_str' }, - shots: { type: 'u32' }, - hits: { type: 'u32' }, - }, - target: { - x: { type: 'quant', min: -20, max: 19.999847412109375, bits: 18 }, - y: { type: 'quant', min: -20, max: 19.999847412109375, bits: 18 }, - z: { type: 'quant', min: 0, max: 4, bits: 12 }, - r: { type: 'f32' }, - flash: { type: 'u8' }, - }, -} as const satisfies Record>; - -/** Client correction defaults (`#[correct(..)]`), by kind and wire field name. */ -export const CORRECTIONS: CorrectionPolicies = { -}; diff --git a/examples/game/client/src/generated/gallery/surface.json b/examples/game/client/src/generated/gallery/surface.json deleted file mode 100644 index 9b94c585..00000000 --- a/examples/game/client/src/generated/gallery/surface.json +++ /dev/null @@ -1,393 +0,0 @@ -{ - "constants": [ - { - "name": "gallery_course", - "type": { - "name": "GalleryCourse", - "type": "record" - }, - "value": { - "circles": [ - { - "cx": -7.0, - "cy": 11.0, - "r": 4.0 - }, - { - "cx": 7.0, - "cy": 11.0, - "r": 3.0 - } - ], - "firing_line": -14.0, - "rails": [ - { - "x0": -12.0, - "x1": 12.0, - "y": 2.0 - }, - { - "x0": -14.0, - "x1": 14.0, - "y": 6.0 - }, - { - "x0": -16.0, - "x1": 16.0, - "y": 16.0 - }, - { - "x0": -10.0, - "x1": 10.0, - "y": -2.0 - } - ] - } - } - ], - "events": [ - { - "id": 1, - "name": "hello", - "payload": { - "fields": [ - { - "name": "entity", - "type": { - "type": "u32" - } - }, - { - "name": "eye", - "type": { - "item": { - "type": "f32" - }, - "type": "list" - } - }, - { - "name": "max_rewind_ms", - "type": { - "type": "u32" - } - }, - { - "name": "range", - "type": { - "type": "f32" - } - }, - { - "name": "half", - "type": { - "type": "f32" - } - } - ], - "kind": "fields" - } - }, - { - "id": 2, - "name": "shot", - "payload": { - "fields": [ - { - "name": "shot", - "type": { - "type": "u16" - } - }, - { - "name": "hit", - "type": { - "item": { - "type": "u32" - }, - "type": "optional" - } - }, - { - "name": "compensated", - "type": { - "type": "bool" - } - }, - { - "name": "age_ms", - "type": { - "type": "f64" - } - }, - { - "name": "requested_age_ms", - "type": { - "type": "f64" - } - }, - { - "name": "clamped", - "type": { - "type": "bool" - } - }, - { - "name": "tick", - "type": { - "type": "u32" - } - } - ], - "kind": "fields" - } - } - ], - "input": { - "fields": [ - { - "buttons": [ - "fire", - "no_comp" - ], - "name": "flags", - "type": { - "type": "u8" - } - }, - { - "name": "shot", - "type": { - "type": "u16" - } - }, - { - "name": "delay_ms", - "type": { - "type": "u16" - } - }, - { - "name": "dx", - "type": { - "bits": 16, - "max": 0.9999695, - "min": -1.0, - "type": "quant" - } - }, - { - "name": "dy", - "type": { - "bits": 16, - "max": 0.9999695, - "min": -1.0, - "type": "quant" - } - }, - { - "name": "dz", - "type": { - "bits": 16, - "max": 0.9999695, - "min": -1.0, - "type": "quant" - } - } - ], - "name": "ShotInput" - }, - "kinds": [ - { - "fields": [ - { - "correction": null, - "name": "x", - "priority": 1.0, - "type": { - "bits": 18, - "max": 19.999847, - "min": -20.0, - "type": "quant" - } - }, - { - "correction": null, - "name": "y", - "priority": 1.0, - "type": { - "bits": 18, - "max": 19.999847, - "min": -20.0, - "type": "quant" - } - }, - { - "correction": null, - "name": "name", - "priority": 0.2, - "type": { - "type": "short_str" - } - }, - { - "correction": null, - "name": "shots", - "priority": 1.0, - "type": { - "type": "u32" - } - }, - { - "correction": null, - "name": "hits", - "priority": 1.0, - "type": { - "type": "u32" - } - } - ], - "id": 1, - "name": "shooter", - "priority": 0.5 - }, - { - "fields": [ - { - "correction": null, - "name": "x", - "priority": 1.0, - "type": { - "bits": 18, - "max": 19.999847, - "min": -20.0, - "type": "quant" - } - }, - { - "correction": null, - "name": "y", - "priority": 1.0, - "type": { - "bits": 18, - "max": 19.999847, - "min": -20.0, - "type": "quant" - } - }, - { - "correction": null, - "name": "z", - "priority": 1.0, - "type": { - "bits": 12, - "max": 4.0, - "min": 0.0, - "type": "quant" - } - }, - { - "correction": null, - "name": "r", - "priority": 1.0, - "type": { - "type": "f32" - } - }, - { - "correction": null, - "name": "flash", - "priority": 1.0, - "type": { - "type": "u8" - } - } - ], - "id": 2, - "name": "target", - "priority": 2.0 - } - ], - "name": "gallery", - "protocolVersion": 1, - "records": [ - { - "fields": [ - { - "name": "cx", - "type": { - "type": "f32" - } - }, - { - "name": "cy", - "type": { - "type": "f32" - } - }, - { - "name": "r", - "type": { - "type": "f32" - } - } - ], - "name": "CircleTrack" - }, - { - "fields": [ - { - "name": "rails", - "type": { - "item": { - "name": "RailTrack", - "type": "record" - }, - "type": "list" - } - }, - { - "name": "circles", - "type": { - "item": { - "name": "CircleTrack", - "type": "record" - }, - "type": "list" - } - }, - { - "name": "firing_line", - "type": { - "type": "f32" - } - } - ], - "name": "GalleryCourse" - }, - { - "fields": [ - { - "name": "y", - "type": { - "type": "f32" - } - }, - { - "name": "x0", - "type": { - "type": "f32" - } - }, - { - "name": "x1", - "type": { - "type": "f32" - } - } - ], - "name": "RailTrack" - } - ], - "schemaVersion": 3 -} diff --git a/examples/game/client/tests/gallery-headless.test.mjs b/examples/game/client/tests/gallery-headless.test.mjs deleted file mode 100644 index 5164f458..00000000 --- a/examples/game/client/tests/gallery-headless.test.mjs +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Headless Shooting Gallery client against the real `game-demos gallery` - * binary: a scripted shooter aims at the fastest target *as rendered* - * under simulated latency. With lag compensation the server confirms the - * hits; without it the same aim mostly misses; beyond the max rewind the - * shots are clamped. - */ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { createGalleryClient, raySphere, targetPosition } from '../src/gallery/client.ts'; -import { BUTTON, decodeInput, encodeInput, sanitizeInput, shooterFields } from '../src/generated/gallery/index.ts'; -import { DevApi } from '../src/shared/dev-api.ts'; -import { memoryStorage, sleep, startDemo, until } from './helpers/demo-server.mjs'; - -const FAST_TARGET = 5; // 12 m/s rail - -/** Fire `n` shots at the rendered fast target, wait for every verdict. */ -async function volley(gallery, n, compensated) { - const records = []; - while (records.length < n) { - await sleep(80); - const eye = gallery.hello().eye; - const target = gallery.targets().find((t) => t.id === FAST_TARGET); - const at = targetPosition(target); - const dir = [at[0] - eye[0], at[1] - eye[1], at[2] - eye[2]]; - // Another target in the line of fire: wait for a clear shot. - if (gallery.predict(dir).target !== FAST_TARGET) continue; - const shot = gallery.shoot(dir, { compensated }); - assert.ok(shot, 'shot sent'); - assert.equal(shot.predicted, FAST_TARGET, 'aimed at what is rendered'); - records.push(shot); - } - const verdicts = await Promise.all(records.map((r) => gallery.verdict(r.id))); - return { - hits: verdicts.filter((v) => v.hit === FAST_TARGET).length, - clamped: verdicts.filter((v) => v.clamped).length, - ages: verdicts.map((v) => v.ageMs), - requested: verdicts.map((v) => v.requestedAgeMs) - }; -} - -test('shot payload and local ray test', () => { - // The generated codec (from the Rust `ShotInput` derive): flags u8, - // varuint shot id and delay, 16-bit centered quantized direction. - const shot = { flags: BUTTON.fire | BUTTON.noComp | 0x80, shot: 0x1234, delayMs: 287, dx: 0, dy: 1, dz: 0 }; - const bytes = encodeInput(shot); - assert.deepEqual([...bytes.slice(0, 5)], [0x83, 0xb4, 0x24, 0x9f, 0x02]); - const back = decodeInput(bytes); - assert.equal(back.dx, 0, 'zero is exact on the centered grid'); - assert.ok(Math.abs(back.dy - 1) < 1e-4); - assert.equal(sanitizeInput(shot).flags, 3, 'unknown flags cleared like the server'); - assert.equal(raySphere([0, 0, 0], [0, 1, 0], [0, 10, 0], 1), 9); - assert.equal(raySphere([0, 0, 0], [0, 1, 0], [0, -10, 0], 1), undefined, 'behind'); - assert.equal(raySphere([0, 0, 0], [0, 1, 0], [2, 10, 0], 1), undefined, 'beside'); -}); - -test('lag compensation confirms what the shooter saw; off misses; beyond max rewind clamps', { timeout: 600_000 }, async (t) => { - const server = await startDemo('gallery'); - const api = new DevApi({ base: server.httpUrl, name: () => 'Sharpshooter', storage: memoryStorage() }); - const gallery = createGalleryClient({ api, base: server.httpUrl }); - try { - await gallery.client.connect('gallery-1'); - const hello = await gallery.ready; - assert.equal(hello.maxRewindMs, 600); - await until(() => gallery.targets().length === 6, 5_000, 'targets'); - - // 100 ms one way (RTT ≈ 200 ms): the client draws targets ~300 ms in the past. - await api.setNetSim({ latencyMs: 100, jitterMs: 0, loss: 0 }); - const zone = gallery.client.authority; - await until(() => zone.connection.clock.rtt > 180, 10_000, 'rtt with latency'); - await until(() => gallery.targets() && gallery.delay(zone).settled(zone, 10), 15_000, 'adaptive delay'); - assert.ok(gallery.delay().value > 190, `delay ${gallery.delay().value}`); - - const on = await volley(gallery, 20, true); - assert.ok(on.hits >= 18, `compensated hits ${on.hits}/20`); - assert.equal(on.clamped, 0); - const meanAge = on.ages.reduce((a, b) => a + b, 0) / on.ages.length; - assert.ok(meanAge > 250 && meanAge < 600, `rewind age ${meanAge}`); - - const off = await volley(gallery, 20, false); - assert.ok(off.hits <= 6, `uncompensated hits ${off.hits}/20`); - assert.ok(off.ages.every((a) => a === 0), 'no rewind without compensation'); - - // 300 ms one way: the view is ~700 ms old, more than the 600 ms clamp. - await api.setNetSim({ latencyMs: 300, jitterMs: 0, loss: 0 }); - await until(() => zone.connection.clock.rtt > 550, 15_000, 'rtt 600'); - await until(() => gallery.targets() && gallery.delay(zone).settled(zone, 10), 20_000, 'adaptive delay 2'); - const far = await volley(gallery, 10, true); - t.diagnostic( - `comp on ${on.hits}/20 (mean rewind ${meanAge.toFixed(0)} ms) · off ${off.hits}/20 · 300 ms: clamped ${far.clamped}/10, hits ${far.hits}/10` - ); - assert.ok(far.clamped >= 9, `clamped ${far.clamped}/10`); - assert.ok(far.requested.every((r) => r > 620), `requested ${far.requested}`); - assert.ok(far.ages.every((a) => a <= 601), `ages ${far.ages}`); - - assert.equal(gallery.stats.resolved, 50); - assert.equal(gallery.stats.predictedHits, 50); - const me = gallery.client.render().find((e) => e.id === hello.entity); - await until( - () => { - const me = gallery.client.render().find((e) => e.id === hello.entity); - return me !== undefined && shooterFields(me).shots === 50; - }, - 5_000, - 'replicated shot counter' - ); - assert.equal(me.fields.name, 'Sharpshooter'); - } finally { - await api.setNetSim({ latencyMs: 0, jitterMs: 0, loss: 0 }).catch(() => undefined); - gallery.client.close(); - await server.stop(); - } -}); diff --git a/examples/game/server/src/gallery/mod.rs b/examples/game/server/src/gallery/mod.rs deleted file mode 100644 index 664d364c..00000000 --- a/examples/game/server/src/gallery/mod.rs +++ /dev/null @@ -1,636 +0,0 @@ -//! **Shooting Gallery** — lag compensation by rewinding hit volumes. -//! -//! Targets race along rails and circles downrange; players stand at fixed -//! stations on the firing line and fire a hitscan ray from their eye. A -//! client renders targets ~one-way latency + two patch intervals in the past -//! (interpolation), so what it aimed at is not where the target is on the -//! server when the shot arrives. With **compensation on** the zone resolves -//! the shot with `ctx.rewind().hit_test_shot` at the shooter's view time -//! (input `client_tick` minus the interpolation delay carried in the -//! payload), clamped to [`MAX_REWIND_MS`]; with it **off** the shot is tested -//! against the latest state. -//! -//! Entities: -//! -//! | kind | ids | fields | -//! |---|---|---| -//! | 1 `shooter` | 1000 + player | x y name shots hits | -//! | 2 `target` | 1..=[`TARGETS`] | x y z r flash | -//! -//! Kinds are `#[derive(Replicated)]` views; inputs are shots only -//! ([`ShotInput`], a `#[derive(GameInput)]`: flags, shot id, view delay and -//! a 16-bit quantized unit direction). The shooter gets one -//! [`GalleryEvent::Shot`] per shot with the verdict and the rewind used. The -//! browser client is generated from [`game_surface`]. - -use std::collections::BTreeMap; -use std::time::Duration; - -use distributed_game::net::{GameServer, ServerConfig, ZoneFactory}; -use distributed_game::rewind::{Historic, HitQuery, RewindFrame, ShotTiming, Vec3}; -use distributed_game::{ - schema, ConsumePolicy, Ctx, EntitySchema, FieldType, GameEvent, GameInput, GameRecord, - GameSurface, LeaveReason, PlayerId, Replicated, View, WorldView, Zone, -}; - -use crate::common::{Demo, Guests}; -/// Floor half extent (m). -pub const HALF: f32 = 20.0; -/// Firing line (m, game y). -pub const FIRING_LINE: f32 = -14.0; -/// Eye height (m). -pub const EYE: f32 = 1.6; -/// Station spacing along the firing line (m). -pub const STATION_SPACING: f32 = 3.0; -/// Stations on the firing line. -pub const STATIONS: u32 = 5; -/// Furthest a shot is rewound (anti-cheat clamp). -pub const MAX_REWIND_MS: u64 = 600; -/// Hitscan range (m). -pub const RANGE: f32 = 80.0; -/// Number of targets. -pub const TARGETS: u32 = 6; -/// Input flag: this input is a shot. -pub const FLAG_FIRE: u8 = ShotInput::FIRE; -/// Input flag: evaluate without lag compensation. -pub const FLAG_NO_COMP: u8 = ShotInput::NO_COMP; - -const POS: FieldType = FieldType::quant_centered(HALF, 18); - -/// A shooter at its station (positions never change while connected). -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "shooter", id = 1, priority = 0.5)] -pub struct ShooterView { - /// Station x. - #[field_type(POS)] - pub x: f32, - /// Station y. - #[field_type(POS)] - pub y: f32, - /// Display name. - #[priority(0.2)] - pub name: String, - /// Shots resolved. - pub shots: u32, - /// Shots that hit. - pub hits: u32, -} - -/// A moving target (global). -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "target", id = 2, priority = 2.0, global)] -pub struct TargetView { - /// Position x. - #[field_type(POS)] - pub x: f32, - /// Position y. - #[field_type(POS)] - pub y: f32, - /// Height. - #[quant(min = 0.0, max = 4.0, bits = 12)] - pub z: f32, - /// Radius. - pub r: f32, - /// Bumps on every confirmed hit. - pub flash: u8, -} - -/// The gallery's events. -#[derive(Debug, Clone, PartialEq, GameEvent)] -pub enum GalleryEvent { - /// To a joining shooter. - #[game_event(id = 1)] - Hello { - /// Own entity id. - entity: u32, - /// Eye position `[x, y, z]` (z up). - eye: Vec, - /// Furthest rewind (anti-cheat clamp). - max_rewind_ms: u32, - /// Hitscan range (m). - range: f32, - /// Floor half extent (m). - half: f32, - }, - /// The verdict of one shot. - #[game_event(id = 2)] - Shot { - /// Client shot id. - shot: u16, - /// Target hit. - hit: Option, - /// Lag compensation used. - compensated: bool, - /// Rewind applied (0 without compensation). - age_ms: f64, - /// Rewind the client asked for. - requested_age_ms: f64, - /// Limited by the clamp / stored history. - clamped: bool, - /// Server tick of the verdict. - tick: u32, - }, -} - -/// The gallery's client surface. -pub fn game_surface() -> GameSurface { - GameSurface::for_zone::("gallery") - .events::() - .constant("gallery_course", &gallery_course()) -} - -/// A rail target's track. -#[derive(Debug, Clone, Copy, PartialEq, GameRecord)] -pub struct RailTrack { - /// y (m). - pub y: f32, - /// West end (m). - pub x0: f32, - /// East end (m). - pub x1: f32, -} - -/// A circling target's track. -#[derive(Debug, Clone, Copy, PartialEq, GameRecord)] -pub struct CircleTrack { - /// Center x (m). - pub cx: f32, - /// Center y (m). - pub cy: f32, - /// Radius (m). - pub r: f32, -} - -/// The course decor the page draws (generated `GALLERY_COURSE`). -#[derive(Debug, Clone, PartialEq, GameRecord)] -pub struct GalleryCourse { - /// Rails, in course order. - pub rails: Vec, - /// Circles, in course order. - pub circles: Vec, - /// y of the firing line (m). - pub firing_line: f32, -} - -/// The `GALLERY_COURSE` constant (from [`COURSE`] and [`FIRING_LINE`]). -pub fn gallery_course() -> GalleryCourse { - let mut course = GalleryCourse { - rails: Vec::new(), - circles: Vec::new(), - firing_line: FIRING_LINE, - }; - for (path, _, _) in COURSE { - match path { - Path::Rail { y, x0, x1, .. } => course.rails.push(RailTrack { y, x0, x1 }), - Path::Circle { cx, cy, r, .. } => course.circles.push(CircleTrack { cx, cy, r }), - } - } - course -} - -/// How a target moves. -#[derive(Debug, Clone, Copy)] -enum Path { - /// Ping-pong along x at `y` between `x0..x1` at `speed` m/s. - Rail { - y: f32, - x0: f32, - x1: f32, - speed: f32, - }, - /// Circle around `(cx, cy)` with radius `r` at `speed` m/s. - Circle { - cx: f32, - cy: f32, - r: f32, - speed: f32, - }, -} - -/// `(path, height, radius)` of every target. -const COURSE: [(Path, f32, f32); TARGETS as usize] = [ - ( - Path::Rail { - y: 2.0, - x0: -12.0, - x1: 12.0, - speed: 3.0, - }, - 1.4, - 0.7, - ), - ( - Path::Rail { - y: 6.0, - x0: -14.0, - x1: 14.0, - speed: 7.0, - }, - 1.8, - 0.6, - ), - ( - Path::Circle { - cx: -7.0, - cy: 11.0, - r: 4.0, - speed: 8.0, - }, - 2.2, - 0.6, - ), - ( - Path::Circle { - cx: 7.0, - cy: 11.0, - r: 3.0, - speed: 6.0, - }, - 1.2, - 0.6, - ), - ( - Path::Rail { - y: 16.0, - x0: -16.0, - x1: 16.0, - speed: 12.0, - }, - 2.6, - 0.6, - ), - ( - Path::Rail { - y: -2.0, - x0: -10.0, - x1: 10.0, - speed: 10.0, - }, - 1.0, - 0.5, - ), -]; - -/// Target position at simulation time `t` (seconds). -pub fn target_position(index: usize, t: f64) -> Vec3 { - let (path, z, _) = COURSE[index]; - match path { - Path::Rail { y, x0, x1, speed } => { - let len = f64::from(x1 - x0); - let d = (t * f64::from(speed)).rem_euclid(2.0 * len); - let along = if d <= len { d } else { 2.0 * len - d }; - [x0 + along as f32, y, z] - } - Path::Circle { cx, cy, r, speed } => { - let a = (t * f64::from(speed) / f64::from(r)).rem_euclid(std::f64::consts::TAU); - [cx + r * a.cos() as f32, cy + r * a.sin() as f32, z] - } - } -} - -/// Radius of target `index` (0-based). -pub fn target_radius(index: usize) -> f32 { - COURSE[index].2 -} - -/// Speed of target `index` (m/s). -pub fn target_speed(index: usize) -> f32 { - match COURSE[index].0 { - Path::Rail { speed, .. } | Path::Circle { speed, .. } => speed, - } -} - -/// One shot (`#[derive(GameInput)]`): flags, shot id, view delay and the -/// aim direction quantized to 16 bits per axis (centered, so a zero axis is -/// exact; the zone re-normalizes, see [`ShotInput::parse`]). -#[derive(Debug, Clone, Copy, PartialEq, GameInput)] -pub struct ShotInput { - /// [`ShotInput::FIRE`] | [`ShotInput::NO_COMP`]. - #[buttons(fire, no_comp)] - pub flags: u8, - /// Client shot id (echoed in the verdict). - pub shot: u16, - /// Interpolation delay behind `client_tick` the shooter was rendering (ms). - pub delay_ms: u16, - /// Aim direction x (game coordinates, z up). - #[quant(centered = 1.0, bits = 16)] - pub dx: f32, - /// Aim direction y. - #[quant(centered = 1.0, bits = 16)] - pub dy: f32, - /// Aim direction z. - #[quant(centered = 1.0, bits = 16)] - pub dz: f32, -} - -impl ShotInput { - /// A shot along `dir` (normalized here). - pub fn new(flags: u8, shot: u16, delay_ms: u16, dir: Vec3) -> Self { - let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]) - .sqrt() - .max(f32::EPSILON); - Self { - flags, - shot, - delay_ms, - dx: dir[0] / len, - dy: dir[1] / len, - dz: dir[2] / len, - } - } - - /// The aim direction. - pub fn dir(&self) -> Vec3 { - [self.dx, self.dy, self.dz] - } - - /// Decode, sanitize and re-normalize a payload (the zone's decode); - /// `None` for a malformed frame or a (near-)zero direction. - pub fn parse(payload: &[u8]) -> Option { - Self::from_payload(payload).and_then(Self::normalized) - } - - /// Re-normalized after quantization; `None` for a (near-)zero direction. - pub fn normalized(self) -> Option { - let [x, y, z] = self.dir(); - let len = (x * x + y * y + z * z).sqrt(); - (len > 0.5).then(|| Self { - dx: x / len, - dy: y / len, - dz: z / len, - ..self - }) - } -} - -/// Per-shooter counters (server truth). -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct ShooterStats { - /// Shots resolved. - pub shots: u32, - /// Shots that hit. - pub hits: u32, - /// Shots whose requested rewind exceeded the clamp. - pub clamped: u32, -} - -/// The verdict sent to the shooter. -#[derive(Debug, Clone, PartialEq)] -pub struct ShotVerdict { - /// Client shot id. - pub shot: u16, - /// Target hit. - pub hit: Option, - /// Lag compensation used. - pub compensated: bool, - /// Rewind applied (0 without compensation). - pub age: Duration, - /// Rewind the client asked for. - pub requested_age: Duration, - /// Limited by [`MAX_REWIND_MS`] / the stored history. - pub clamped: bool, -} - -struct Shooter { - entity: u32, - name: String, - station: u32, - stats: ShooterStats, -} - -impl Shooter { - fn eye(&self) -> Vec3 { - let (x, y) = station_position(self.station); - [x, y, EYE] - } -} - -/// Station `i` position on the firing line. -pub fn station_position(station: u32) -> (f32, f32) { - let offset = station as f32 - (STATIONS - 1) as f32 / 2.0; - (offset * STATION_SPACING, FIRING_LINE) -} - -/// Zone construction parameters. -#[derive(Clone, Default)] -pub struct GalleryConfig { - /// Display names registered by the dev ticket endpoint. - pub guests: Guests, -} - -/// The Shooting Gallery zone. -pub struct Gallery { - guests: Guests, - shooters: BTreeMap, - targets: Vec, - flash: Vec, -} - -impl Gallery { - /// Server-side counters of a player. - pub fn stats(&self, player: PlayerId) -> Option { - self.shooters.get(&player).map(|s| s.stats) - } - - /// Current target positions. - pub fn targets(&self) -> &[Vec3] { - &self.targets - } - - fn free_station(&self) -> u32 { - (0..STATIONS) - .find(|i| self.shooters.values().all(|s| s.station != *i)) - .unwrap_or(self.shooters.len() as u32 % STATIONS) - } - - /// Resolve one shot of `player` (compensated or not). - pub fn resolve_shot( - &self, - ctx: &Ctx<'_, Self>, - player: PlayerId, - client_tick: u32, - shot: &ShotInput, - ) -> Option { - let shooter = self.shooters.get(&player)?; - let query = HitQuery::ray(shooter.eye(), shot.dir(), RANGE); - let rewind = ctx.rewind(); - let latest = rewind.latest()?.time(); - let compensated = shot.flags & FLAG_NO_COMP == 0; - let timing = ShotTiming { - client_tick, - interp_delay: Duration::from_millis(shot.delay_ms.into()), - }; - let resolved = rewind.resolve(timing)?; - let (time, age) = if compensated { - (resolved.time, resolved.age) - } else { - (latest, Duration::ZERO) - }; - let hit = rewind - .hit_test(time, &query, |id| (1..=TARGETS).contains(&id)) - .map(|h| h.id); - Some(ShotVerdict { - shot: shot.shot, - hit, - compensated, - age, - requested_age: resolved.requested_age, - clamped: compensated && resolved.clamped, - }) - } -} - -impl Zone for Gallery { - type Config = GalleryConfig; - type Input = ShotInput; - type Timer = (); - type Message = distributed_game::NoMessages; - - fn schema() -> EntitySchema { - schema![ShooterView, TargetView] - } - - fn on_create(ctx: &mut Ctx<'_, Self>, config: GalleryConfig) -> Self { - let t = ctx.sim_time().as_secs_f64(); - Self { - guests: config.guests, - shooters: BTreeMap::new(), - targets: (0..TARGETS as usize) - .map(|i| target_position(i, t)) - .collect(), - flash: vec![0; TARGETS as usize], - } - } - - fn on_join(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId) { - let principal = ctx - .player(player) - .map(|p| p.principal.clone()) - .unwrap_or_default(); - let name = self.guests.name(&principal).unwrap_or(principal); - let station = self.free_station(); - let shooter = Shooter { - entity: 1000 + player.0, - name, - station, - stats: ShooterStats::default(), - }; - let eye = shooter.eye(); - let entity = shooter.entity; - self.shooters.insert(player, shooter); - ctx.set_view(player, View::new(0.0, 0.0, 100.0)); - ctx.send( - player, - &GalleryEvent::Hello { - entity, - eye: eye.to_vec(), - max_rewind_ms: MAX_REWIND_MS as u32, - range: RANGE, - half: HALF, - }, - ); - } - - fn on_leave(&mut self, _ctx: &mut Ctx<'_, Self>, player: PlayerId, _reason: LeaveReason) { - self.shooters.remove(&player); - } - - fn decode_input(&self, _player: PlayerId, payload: &[u8]) -> Option { - ShotInput::parse(payload).filter(|s| s.flags & FLAG_FIRE != 0) - } - - fn on_tick(&mut self, ctx: &mut Ctx<'_, Self>) { - // Shots first, against the history up to the previous tick (what - // every client has been sent so far). - let players: Vec = self.shooters.keys().copied().collect(); - for player in players { - for input in ctx.take_inputs(player, ConsumePolicy::Drain) { - let Some(verdict) = self.resolve_shot(ctx, player, input.client_tick, &input.input) - else { - continue; - }; - let shooter = self.shooters.get_mut(&player).expect("present"); - shooter.stats.shots += 1; - shooter.stats.clamped += u32::from(verdict.clamped); - if let Some(id) = verdict.hit { - shooter.stats.hits += 1; - let flash = &mut self.flash[(id - 1) as usize]; - *flash = flash.wrapping_add(1); - } - let tick = ctx.tick() as u32; - ctx.send( - player, - &GalleryEvent::Shot { - shot: verdict.shot, - hit: verdict.hit, - compensated: verdict.compensated, - age_ms: verdict.age.as_secs_f64() * 1000.0, - requested_age_ms: verdict.requested_age.as_secs_f64() * 1000.0, - clamped: verdict.clamped, - tick, - }, - ); - } - } - let t = ctx.sim_time().as_secs_f64(); - for (i, pos) in self.targets.iter_mut().enumerate() { - *pos = target_position(i, t); - } - } - - fn record_rewind(&self, frame: &mut RewindFrame) { - for (i, pos) in self.targets.iter().enumerate() { - frame.push(Historic::sphere(i as u32 + 1, *pos, target_radius(i))); - } - } - - fn replicate(&self, out: &mut WorldView) { - for s in self.shooters.values() { - let (x, y) = station_position(s.station); - let view = ShooterView { - x, - y, - name: s.name.clone(), - shots: s.stats.shots, - hits: s.stats.hits, - }; - out.add(s.entity, &view); - } - for (i, p) in self.targets.iter().enumerate() { - let view = TargetView { - x: p[0], - y: p[1], - z: p[2], - r: target_radius(i), - flash: self.flash[i], - }; - out.add(i as u32 + 1, &view); - } - } -} - -/// Demo registration (`game-demos gallery`). -pub struct GalleryDemo; - -impl Demo for GalleryDemo { - type Zone = Gallery; - const NAME: &'static str = "gallery"; - const DEFAULT_ZONE: &'static str = "gallery-1"; - - fn hosts_zone(zone_id: &str) -> bool { - zone_id.starts_with("gallery-") - } - - fn server(mut config: ServerConfig, guests: Guests) -> GameServer { - config.host.rewind.max_rewind = Duration::from_millis(MAX_REWIND_MS); - GameServer::new( - config, - ZoneFactory::sync(move |activation| { - let zone_id = activation.zone_id.as_str(); - Self::hosts_zone(zone_id).then(|| GalleryConfig { - guests: guests.clone(), - }) - }), - ) - } -} diff --git a/examples/game/server/tests/gallery_smoke.rs b/examples/game/server/tests/gallery_smoke.rs deleted file mode 100644 index 5cec901b..00000000 --- a/examples/game/server/tests/gallery_smoke.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Shooting Gallery: deterministic lag-compensation checks on a -//! `ZoneHost` driven by a `ManualClock` (the end-to-end run under simulated -//! latency is `client/tests/gallery-headless.test.mjs`). - -use std::sync::Arc; -use std::time::Duration; - -use distributed_game::GameInput; -use distributed_game::{HostConfig, ManualClock, Role, TicketClaims, Viewer, ZoneHost}; -use game_demos::gallery::{ - self, Gallery, GalleryConfig, ShotInput, FLAG_FIRE, FLAG_NO_COMP, MAX_REWIND_MS, -}; - -const DT: Duration = Duration::from_nanos(33_333_333); - -struct Rig { - clock: ManualClock, - host: ZoneHost, - client: distributed_game::ClientId, - player: distributed_game::PlayerId, - seq: u32, -} - -impl Rig { - fn new() -> Self { - let clock = ManualClock::new(); - let mut config = HostConfig::default(); - config.rewind.max_rewind = Duration::from_millis(MAX_REWIND_MS); - let mut host = ZoneHost::::new( - "gallery-1", - config, - Arc::new(clock.clone()), - GalleryConfig::default(), - ); - let joined = host - .join( - &TicketClaims { - principal: "guest-test".into(), - zone: "gallery-1".into(), - expires_at: u64::MAX, - nonce: "n1".into(), - role: Role::Player, - name: None, - extensions: Default::default(), - }, - 0, - "", - ) - .unwrap(); - let Viewer::Player(player) = joined.viewer else { - panic!("player") - }; - let mut rig = Self { - clock, - host, - client: joined.client, - player, - seq: 0, - }; - rig.ticks(60); - rig - } - - fn ticks(&mut self, n: u32) { - for _ in 0..n { - self.clock.advance(DT); - self.host.advance(); - } - self.host.drain_outbound(); - } - - /// Aim from station 0's eye at target `id` as it was `ago` ticks - /// before the latest recorded tick; claim `delay_ms` of interpolation. - fn shoot(&mut self, id: u32, ago: u64, delay_ms: u16, flags: u8) -> gallery::ShooterStats { - let latest = self.host.rewind().latest().unwrap().tick(); - let seen = self - .host - .rewind() - .at(latest - ago) - .and_then(|f| f.get(id)) - .unwrap() - .pos; - let (x, y) = gallery::station_position(0); - let d = [seen[0] - x, seen[1] - y, seen[2] - gallery::EYE]; - let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(); - self.seq += 1; - let shot = ShotInput::new( - FLAG_FIRE | flags, - self.seq as u16, - delay_ms, - [d[0] / len, d[1] / len, d[2] / len], - ); - self.host - .push_input(self.client, self.seq, latest as u32, &shot.to_payload()); - self.ticks(1); - self.host.zone().stats(self.player).unwrap() - } -} - -#[test] -fn shot_payload_round_trips() { - let shot = ShotInput::new(FLAG_FIRE | FLAG_NO_COMP | 0x80, 513, 287, [0.6, 0.8, 0.0]); - let back = ShotInput::parse(&shot.to_payload()).unwrap(); - assert_eq!( - (back.flags, back.shot, back.delay_ms), - (3, 513, 287), - "unknown flags cleared" - ); - assert!((back.dx - 0.6).abs() < 1e-4 && (back.dy - 0.8).abs() < 1e-4); - assert_eq!(ShotInput::parse(&[1, 0, 0]), None, "truncated"); - assert_eq!(ShotInput::parse(&[1; 10]), None, "trailing bytes"); - let zero = ShotInput::new(FLAG_FIRE, 1, 0, [0.0, 0.0, 0.0]); - assert_eq!(ShotInput::parse(&zero.to_payload()), None, "zero direction"); -} - -#[test] -fn rewound_shots_hit_what_the_shooter_saw() { - let mut rig = Rig::new(); - // Fast rail target (id 5, 12 m/s) as rendered 300 ms ago: 9 ticks. - let s = rig.shoot(5, 9, 300, 0); - assert_eq!((s.shots, s.hits, s.clamped), (1, 1, 0), "compensated hit"); - // Same aim without compensation: the target moved ~3.6 m. - let s = rig.shoot(5, 9, 300, FLAG_NO_COMP); - assert_eq!((s.shots, s.hits), (2, 1), "uncompensated miss"); - // Uncompensated shots hit only when aimed at the present. - let s = rig.shoot(5, 0, 0, FLAG_NO_COMP); - assert_eq!(s.hits, 2); - // Claiming 2 s of delay is clamped to MAX_REWIND_MS: aiming 2 s back - // misses, aiming at the clamp (18 ticks = 600 ms) still hits. - let s = rig.shoot(5, 18, 2000, 0); - assert_eq!((s.shots, s.hits, s.clamped), (4, 3, 1)); - let s = rig.shoot(2, 18, 2000, 0); - assert_eq!((s.shots, s.clamped), (5, 2)); -} - -#[test] -fn targets_move_deterministically() { - let a = gallery::target_position(4, 1.0); - let b = gallery::target_position(4, 1.0 + 1.0 / 30.0); - let step = ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt(); - assert!( - (step - gallery::target_speed(4) / 30.0).abs() < 1e-3, - "{step}" - ); - for i in 0..gallery::TARGETS as usize { - for k in 0..300 { - let p = gallery::target_position(i, f64::from(k) * 0.1); - assert!(p[0].abs() < gallery::HALF && p[1].abs() < gallery::HALF); - } - } -} From 0f38f4cb2739fd3346c815a0ee58cf09d5933227 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 21 Oct 2026 02:32:59 -0500 Subject: [PATCH 11/21] feat(game)!: Mob Pit bow and first person; delete Shooting Gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mob Pit gets a ranged weapon with lag compensation: the input frame carries the aim (yaw, pitch), a shot id and the view delay behind client_tick; the room records the mobs' hit capsules every tick and resolves each arrow with ctx.rewind() at the shooter's view time (clamped to 600 ms), or the latest state with compensation off. Obstacles stop arrows, a jump raises the eye, the shooter gets a verdict per shot. First person (V): pointer-lock mouse look, you face where you look (aim bit), arrows leave from the eye with the look's pitch; third person and the SvelteKit top-down view shoot level at the hip along the aim. Both pages predict the hit against what they render (yellow marker) and show the verdict (green / red); C toggles compensation. Doorways into sleeping rooms keep up to 3 s of inputs. The Shooting Gallery demo is deleted. Tests: rooms `an_arrow_hits_the_mob…` (rewind, no-comp, pillar), mobpit-ranged-headless (8/8 compensated arrows confirmed as seen under 80 ms one-way latency), predict-trace replays aim/shot inputs, SvelteKit looses arrows in first person. Refs [[tasks/gr-p44-mobpit-world]] --- examples/game/README.md | 50 ++-- examples/game/client/index.html | 1 - .../client/src/generated/mobpit/constants.ts | 8 +- .../client/src/generated/mobpit/events.ts | 39 +++ .../game/client/src/generated/mobpit/input.ts | 17 +- .../client/src/generated/mobpit/schema.ts | 5 + .../client/src/generated/mobpit/surface.json | 162 +++++++++- examples/game/client/src/mobpit/client.ts | 134 +++++++++ examples/game/client/src/mobpit/main.ts | 80 ++++- examples/game/client/src/mobpit/style.css | 12 + examples/game/client/src/shared/scene.ts | 12 +- .../tests/mobpit-ranged-headless.test.mjs | 81 +++++ examples/game/client/vite.config.ts | 2 +- examples/game/run.sh | 4 +- examples/game/server/rooms/src/celld.rs | 8 + examples/game/server/rooms/src/zone.rs | 278 ++++++++++++++++-- examples/game/server/rooms/tests/rooms.rs | 102 +++++++ examples/game/server/sim/src/mobpit.rs | 67 ++++- examples/game/server/src/common/mod.rs | 2 +- examples/game/server/src/lib.rs | 1 - examples/game/server/src/main.rs | 5 - examples/game/server/src/predict_trace.rs | 32 +- .../src/lib/generated/mobpit/constants.ts | 8 +- .../src/lib/generated/mobpit/events.ts | 39 +++ .../src/lib/generated/mobpit/input.ts | 17 +- .../src/lib/generated/mobpit/schema.ts | 5 + .../src/lib/generated/mobpit/surface.json | 162 +++++++++- examples/game/sveltekit/src/lib/pit/loop.ts | 206 ++++++++++++- .../sveltekit/src/routes/pit/+page.svelte | 18 +- examples/game/sveltekit/tests/pit.test.mjs | 18 ++ 30 files changed, 1486 insertions(+), 89 deletions(-) create mode 100644 examples/game/client/tests/mobpit-ranged-headless.test.mjs diff --git a/examples/game/README.md b/examples/game/README.md index e49b1953..47b0b1fe 100644 --- a/examples/game/README.md +++ b/examples/game/README.md @@ -10,7 +10,6 @@ guests and lets any page change the network simulation. |---|---|---|---| | **Mob Pit** | `./run.sh mobpit` (`--fleet` for a local multi-node celld) | A near-infinite seeded grid of rooms joined by doorways, **each room a celld Durable Object** (celld places, forwards, hibernates and recovers rooms; the debug overlay shows the node); authoritative melee combat vs. telegraphing mobs; **hoods, boroughs and a city** above the rooms (each tier a celld cell: a seeded boss room per hood, a boss kill raises the hood's tier — tougher rooms — and a saga ripples it up; the borough's weather and darkness lift as its bosses fall, fog thickens toward boss rooms); the **rooms next door** watched as passive observers (their mobs are drawn; a watcher never wakes a sleeping room or keeps one awake); a **network panel** (latency / jitter / loss applied by every room object on celld, client prediction of the Rust step as WASM with reconciliation, a server ghost, an interest ring: what is outside is not replicated); kills are **non-blocking durable facts** settled exactly once in per-player cells; rooms you leave go dormant then asleep (live minimap from the zone status read model) and come back with their mobs | celld: `pit..` rooms (snapshots), `crawler/{player}`, `presence/{player}`, `zone-activity/{room}`; crawler list and room status = read models projected from the cells' events | | **Leaderboard** | `./run.sh leaderboard` | Same setup as Mob Pit: kills/XP sorted-set boards (in-memory or Redis) fed from the projected crawler list with idempotent `max` updates, your rank + neighbours, rebuild from the cells' event history | celld (the Mob Pit cells); boards are a projection (memory or Redis) | -| **Shooting Gallery** | `./run.sh gallery` | Lag compensation: hitscan shots resolved with `ctx.rewind().hit_test_shot` at the shooter's view time (`client_tick` + interpolation delay in the payload), `max_rewind` clamp, per-shot compensation on/off, predicted vs confirmed hits | none (stateless: score is per session) | | **The Freeze** | `./run.sh freeze` | World-version barrier (`WorldCoordinator::rollout`) across **two server processes** as an in-world "admin update": freeze → apply → resume with change notes, inputs dropped while frozen, Hard reload vs safe-room gating (`StalePolicy::SafeRoom`, `Zone::is_gate`) of old clients, aborted rollout (`freeze_ready` → `NotAcked`), facts/events stamped with the content version | celld: `gem-pouch/{player}`, `content-log/{zone}` | | **Mob Pit · SvelteKit** | `REDIS_URL=… ./run.sh sveltekit` | The same Mob Pit server behind a SvelteKit app: Auth.js session sign-in, admission tickets minted from the session (`createGameTicketHandler`), the game client and WASM step generated by the Vite plugin (`games`), a runes HUD (`useGame`) next to a plain canvas loop, the settled kills as a business-side read model (Crawler domain events projected by `mobpit-service` into SQL, read through generated `@load @live` GraphQL), and the zone server's Redis XP board through GraphQL board roots, live across processes over the bus. See [SvelteKit](#sveltekit) | celld (the Mob Pit cells) + `mobpit-service`'s SQL read model + the Redis boards | @@ -22,7 +21,7 @@ list` lists demos. `distributed_game` authoring derives: `#[derive(Replicated)]` view structs for the kinds (`mobpit::zone::PlayerView`, `mobpit::zone::MobView`, …), `#[derive(GameInput)]` for the input frame (`game_sim::mobpit::PitInput`, -`gallery::ShotInput`, …), `#[derive(GameEvent)]` enums for events +`freeze`'s `MoveInput`, …), `#[derive(GameEvent)]` enums for events (`mobpit::zone::PitEvent`) and `#[derive(FactCommand)]` on the cell command inputs they emit as facts (`mobpit::crawler::RecordKillInput`). The movement that clients predict is declared once with `#[predict]` in @@ -43,13 +42,11 @@ step; Mob Pit's rooms (biome, danger, mobs) come from the compiled `pit_room` (`createMobpitPures`); static data the pages draw is a generated constant from the Rust values (`PIT_WORLD` — room size, doorways, pillars, capsule radius —, `WEATHER` names, `ARENA_FLOOR` -for The Freeze, `FREEZE_WORLD`, `PIT_RULES` tier radii, `GALLERY_COURSE` -rails); The Freeze's content packs are a Rust +for The Freeze, `FREEZE_WORLD`, `PIT_RULES` tier radii, ranged reach, +eye and hip heights, mob heights); The Freeze's content packs are a Rust record served in its payload encoding and decoded by the generated `decodePack`. What TypeScript still mirrors is small and listed under -[Remaining client-side mirrors](#remaining-client-side-mirrors). The -Shooting Gallery predicts hits by ray-testing what it renders, not -movement, so it has no predicted step. +[Remaining client-side mirrors](#remaining-client-side-mirrors). Requirements: Rust (stable) with the `wasm32-unknown-unknown` target (`rustup target add wasm32-unknown-unknown`), Node ≥ 22.18 (runs `.ts` @@ -81,17 +78,20 @@ second and delays its sockets' frames and inputs (`distributed_game::netsim`, `ZONE_DEV_SETTINGS=1`). The dashed ring is your interest radius (`hello.viewRadius`, 24 m): mobs and players outside it are not replicated. -Shooting Gallery controls: click a target (or Space / gamepad A through the -crosshair), drag to look. **C** toggles lag compensation (sent with every -shot), **G** wireframe ghosts at the newest received target state. Set the -NetSim latency to 150–250 ms: with compensation the confirmed hits match the -predicted ones (yellow marker → green); without it most turn red. The -client's interpolation delay adapts to `rtt / 2 + 2 patches + 2 × jitter` -(the SDK clock estimates the server's *present*, so a fixed 100 ms would ask -for states not yet received); each shot carries `delay = tickTime(client_tick) -- renderTime`, so the server rewinds to exactly what was on screen. Beyond -`gallery::MAX_REWIND_MS` (600 ms of view age, ≈ 250 ms one way) shots are -clamped and reported as such. +**Bow and first person** (Mob Pit, from the Shooting Gallery): **R** or a +click looses an arrow, **V** toggles first person (click to capture the +mouse; you face where you look and arrows leave from the eye with the +look's pitch — in third person they fly level at the hip along the +camera's heading), **C** toggles lag compensation per shot. The page +predicts the hit by ray-testing the mobs *as rendered* (a yellow marker); +the room resolves the arrow with `ctx.rewind()` at the shooter's view time +(`client_tick` minus the interpolation delay the input carries, clamped to +`MAX_REWIND_MS` = 600 ms; the mobs' hit capsules are recorded every tick) +and answers with a verdict: the marker turns green (hit) or red (miss). +Obstacles stop arrows; a jump raises the eye. The input frame carries the +aim (yaw, pitch), the shot id and the view delay (`PitInput`, 11–14 +bytes). The SvelteKit page aims with the mouse (top-down), and its "first +person" turns the canvas with the aim under pointer lock. The Freeze: the server prints an **admin secret** (`./run.sh freeze` shows it; set `DG_DEMO_ADMIN_SECRET` to choose one) — open @@ -301,9 +301,9 @@ What the TypeScript still restates, and why it stays: any module loads; a mismatch fails loudly (the server rejects the zone). - **Dev / admin HTTP JSON shapes** — `WorldMap`, `WorldStatus`, `BoardPosition`: debugging routes outside the game surface. -- **The Shooting Gallery's predicted hit** (`raySphere` over rendered - targets) — cosmetic feedback; the server's rewound `HitQuery` decides. - It could become a `#[game_pure]` over the rendered targets. +- **The predicted arrow hit** (`rayCapsule` over rendered mobs) — cosmetic + feedback; the room's rewound `HitQuery` decides. It could become a + `#[game_pure]` over the rendered mobs. - **Presentation** — colors, tier display names, camera and HUD constants: client-only by nature. @@ -345,10 +345,9 @@ examples/game/ src/devcelld.rs `celld dev` harness for tests (own project, port, process tree) src/mobpit/ Mob Pit services (the rooms run on celld): dev room tickets, relay, crawler directory and room status projections, leaderboards - src/gallery/ Shooting Gallery zone (targets, rewind hit tests) src/freeze/ The Freeze zone, content packs, two-host world + admin routes tests/ mobpit_smoke.rs (facts, rooms on celld, celld crash, boards, presence), - gallery_smoke.rs, freeze_smoke.rs (binary content packs), + freeze_smoke.rs (binary content packs), (Boss Ripple and the multi-host plumbing are gone: Mob Pit's tiers are cells) sveltekit/ SvelteKit app over the Mob Pit server + mobpit-service (see SvelteKit above) distributed.config.js game surface (`games`) + GraphQL client (`clients`) for the Vite plugin @@ -372,12 +371,11 @@ examples/game/ src/shared/ hud.ts, input.ts, scene.ts, dev-api.ts, latest.ts, style.css src/mobpit/ client.ts (headless + /dev/mobpit API), main.ts (Babylon), style.css src/leaderboard/ main.ts (DOM page over the observer connection), style.css - src/gallery/ client.ts (shots, adaptive delay), main.ts src/freeze/ client.ts (content packs, live patch / reload, admin API), main.ts tests/ prediction-wasm (native ↔ WASM exact match of steps + pure functions, cost), mobpit-headless, mobpit-network-headless (NetSim on celld rooms, exact prediction, interest), mobpit-restarts-headless, leaderboard-headless, - mobpit-neighbours-headless (passive watchers), gallery-headless, freeze-headless, + mobpit-neighbours-headless (passive watchers), mobpit-ranged-headless (lag-compensated arrows), mobpit-events-headless, freeze-headless, mobpit-bosses-headless (hood → borough → city) (+ helpers/demo-server.mjs, helpers/mobpit.mjs, helpers/celld.mjs for a per-test `celld dev`) @@ -608,7 +606,7 @@ counts exactly 200 in the cell. version pinned in the repository's `.celld-version` (`CELLD_BIN`, default `celld` on PATH; `scripts/celld.sh check` verifies it and prints the install command) and `worker-build` (`cargo install worker-build --locked`) for -`cells/`. Only `gallery` runs without celld. The tests each +`cells/`. The tests each start their own `celld dev` on a temp project (`game_demos::devcelld`, `client/tests/helpers/celld.mjs`); the SvelteKit Playwright test starts a 3-node fleet (`client/tests/helpers/fleet.mjs`) when docker and the aws CLI diff --git a/examples/game/client/index.html b/examples/game/client/index.html index 25e94836..2b5844c3 100644 --- a/examples/game/client/index.html +++ b/examples/game/client/index.html @@ -13,7 +13,6 @@

    distributed_game demos

    • Mob Pit — authoritative combat, non-blocking durable facts into per-player celld cells, slow storage, crash recovery.
    • Leaderboard — kills/XP sorted-set boards projected from the cells, pushed live, rebuilt from events (runs with Mob Pit).
    • -
    • Shooting Gallery — lag compensation: hitscan shots rewound to the shooter's view time, max-rewind clamp, compensation on/off.
    • The Freeze — world-version barrier across two server processes as a story beat: hard reload, safe-room gating of old clients, aborted rollout.
    diff --git a/examples/game/client/src/generated/mobpit/constants.ts b/examples/game/client/src/generated/mobpit/constants.ts index d1f859de..0d224eb4 100644 --- a/examples/game/client/src/generated/mobpit/constants.ts +++ b/examples/game/client/src/generated/mobpit/constants.ts @@ -17,7 +17,13 @@ export const PIT_RULES: PitRules = { states: ['idle', 'chase', 'windup', 'recover', 'return', 'dead'], attackRange: 2.200000047683716, attackHalfAngle: 1.0471975803375244, - swingFrames: 6 + swingFrames: 6, + range: 30, + fireCooldown: 12, + eyeHeight: 1.5, + hipHeight: 0.6000000238418579, + maxRewindMs: 600, + mobHeights: [0.8999999761581421, 0.8999999761581421, 1.5, 2, 2.799999952316284] }; /** `pit_world`, as the client decodes it. */ diff --git a/examples/game/client/src/generated/mobpit/events.ts b/examples/game/client/src/generated/mobpit/events.ts index d0a40fdb..d42a4f85 100644 --- a/examples/game/client/src/generated/mobpit/events.ts +++ b/examples/game/client/src/generated/mobpit/events.ts @@ -59,6 +59,12 @@ export interface PitRules { readonly attackRange: number; readonly attackHalfAngle: number; readonly swingFrames: number; + readonly range: number; + readonly fireCooldown: number; + readonly eyeHeight: number; + readonly hipHeight: number; + readonly maxRewindMs: number; + readonly mobHeights: readonly number[]; } /** Record `PitState` (`#[derive(GameRecord)]`). */ @@ -257,6 +263,31 @@ const BOSS_DOWN_LAYOUT: PayloadLayout = [ ['hoodTier', { type: 'u32' }], ]; +/** Event `shot` (id 9). */ +export interface ShotEvent { + readonly shot: number; + readonly hit: number | undefined; + readonly compensated: boolean; + readonly ageMs: number; + readonly requestedAgeMs: number; + readonly clamped: boolean; + readonly x: number; + readonly y: number; + readonly z: number; +} + +const SHOT_LAYOUT: PayloadLayout = [ + ['shot', { type: 'u16' }], + ['hit', { type: 'optional', item: { type: 'u32' } }], + ['compensated', { type: 'bool' }], + ['ageMs', { type: 'f32' }], + ['requestedAgeMs', { type: 'f32' }], + ['clamped', { type: 'bool' }], + ['x', { type: 'f32' }], + ['y', { type: 'f32' }], + ['z', { type: 'f32' }], +]; + /** Message `loaded` (id 1). */ export interface LoadedMessage { readonly principal: string; @@ -291,6 +322,7 @@ export const EVENT = { died: 6, world: 7, bossDown: 8, + shot: 9, } as const; /** Event payload types by name. */ @@ -302,6 +334,7 @@ export interface Events { died: DiedEvent; world: WorldEvent; bossDown: BossDownEvent; + shot: ShotEvent; } /** Codecs of every event. */ @@ -348,6 +381,12 @@ export const EVENTS: GameEventCodecs = { encode: (value) => encodePayload(BOSS_DOWN_LAYOUT, value), decode: (payload) => decodePayload(BOSS_DOWN_LAYOUT, payload) as unknown as BossDownEvent }, + shot: { + id: 9, + name: 'shot', + encode: (value) => encodePayload(SHOT_LAYOUT, value), + decode: (payload) => decodePayload(SHOT_LAYOUT, payload) as unknown as ShotEvent + }, }; /** A decoded event, discriminated by `name`. */ diff --git a/examples/game/client/src/generated/mobpit/input.ts b/examples/game/client/src/generated/mobpit/input.ts index 9e785027..1940d55a 100644 --- a/examples/game/client/src/generated/mobpit/input.ts +++ b/examples/game/client/src/generated/mobpit/input.ts @@ -8,6 +8,10 @@ export interface PitInput { readonly mx: number; readonly my: number; readonly buttons: number; + readonly yaw: number; + readonly pitch: number; + readonly shot: number; + readonly delayMs: number; } export type Input = PitInput; @@ -18,6 +22,9 @@ export const BITS = { dash: 1, attack: 2, jump: 4, + fire: 8, + noComp: 16, + aim: 32, }, } as const; @@ -29,12 +36,20 @@ export const INPUT_LAYOUT: PayloadLayout = [ ['mx', { type: 'i8' }], ['my', { type: 'i8' }], ['buttons', { type: 'u8' }], + ['yaw', { type: 'quant', min: -3.200000047683716, max: 3.199902296066284, bits: 16 }], + ['pitch', { type: 'quant', min: -1.600000023841858, max: 1.599951148033142, bits: 16 }], + ['shot', { type: 'u16' }], + ['delayMs', { type: 'u16' }], ]; const INPUT_RULES: readonly InputRule[] = [ { range: [-127, 127] }, { range: [-127, 127] }, - { buttons: 7 }, + { buttons: 63 }, + undefined, + undefined, + undefined, + undefined, ]; /** Payload bytes of one input frame. */ diff --git a/examples/game/client/src/generated/mobpit/schema.ts b/examples/game/client/src/generated/mobpit/schema.ts index 62157019..0f2e9e2f 100644 --- a/examples/game/client/src/generated/mobpit/schema.ts +++ b/examples/game/client/src/generated/mobpit/schema.ts @@ -43,6 +43,8 @@ export interface PlayerFields { readonly level: number; /** `bool` */ readonly dead: boolean; + /** `u8` */ + readonly shot: number; } /** True for a `player` entity. */ @@ -68,6 +70,7 @@ export function playerFields(entity: { readonly fields: EntityFields }): PlayerF swing: fieldNumber(f, 'swing'), level: fieldNumber(f, 'level'), dead: fieldBool(f, 'dead'), + shot: fieldNumber(f, 'shot'), }; } @@ -184,6 +187,7 @@ export const FIELD_TYPES = { swing: { type: 'u8' }, level: { type: 'u16' }, dead: { type: 'bool' }, + shot: { type: 'u8' }, }, mob: { x: { type: 'quant', min: -20, max: 20, bits: 14 }, @@ -226,5 +230,6 @@ export const CORRECTIONS: CorrectionPolicies = { swing: { mode: 'replace' }, level: { mode: 'replace' }, dead: { mode: 'replace' }, + shot: { mode: 'replace' }, }, }; diff --git a/examples/game/client/src/generated/mobpit/surface.json b/examples/game/client/src/generated/mobpit/surface.json index 4bb75a3d..19f49d99 100644 --- a/examples/game/client/src/generated/mobpit/surface.json +++ b/examples/game/client/src/generated/mobpit/surface.json @@ -26,6 +26,18 @@ "value": { "attack_half_angle": 1.0471975803375244, "attack_range": 2.200000047683716, + "eye_height": 1.5, + "fire_cooldown": 12, + "hip_height": 0.6000000238418579, + "max_rewind_ms": 600, + "mob_heights": [ + 0.8999999761581421, + 0.8999999761581421, + 1.5, + 2.0, + 2.799999952316284 + ], + "range": 30.0, "states": [ "idle", "chase", @@ -408,6 +420,72 @@ ], "kind": "fields" } + }, + { + "id": 9, + "name": "shot", + "payload": { + "fields": [ + { + "name": "shot", + "type": { + "type": "u16" + } + }, + { + "name": "hit", + "type": { + "item": { + "type": "u32" + }, + "type": "optional" + } + }, + { + "name": "compensated", + "type": { + "type": "bool" + } + }, + { + "name": "age_ms", + "type": { + "type": "f32" + } + }, + { + "name": "requested_age_ms", + "type": { + "type": "f32" + } + }, + { + "name": "clamped", + "type": { + "type": "bool" + } + }, + { + "name": "x", + "type": { + "type": "f32" + } + }, + { + "name": "y", + "type": { + "type": "f32" + } + }, + { + "name": "z", + "type": { + "type": "f32" + } + } + ], + "kind": "fields" + } } ], "input": { @@ -436,12 +514,45 @@ "buttons": [ "dash", "attack", - "jump" + "jump", + "fire", + "no_comp", + "aim" ], "name": "buttons", "type": { "type": "u8" } + }, + { + "name": "yaw", + "type": { + "bits": 16, + "max": 3.1999023, + "min": -3.2, + "type": "quant" + } + }, + { + "name": "pitch", + "type": { + "bits": 16, + "max": 1.5999511, + "min": -1.6, + "type": "quant" + } + }, + { + "name": "shot", + "type": { + "type": "u16" + } + }, + { + "name": "delay_ms", + "type": { + "type": "u16" + } } ], "name": "PitInput" @@ -610,6 +721,16 @@ "type": { "type": "bool" } + }, + { + "correction": { + "mode": "replace" + }, + "name": "shot", + "priority": 1.0, + "type": { + "type": "u8" + } } ], "id": 1, @@ -1059,6 +1180,45 @@ "type": { "type": "u8" } + }, + { + "name": "range", + "type": { + "type": "f32" + } + }, + { + "name": "fire_cooldown", + "type": { + "type": "u8" + } + }, + { + "name": "eye_height", + "type": { + "type": "f32" + } + }, + { + "name": "hip_height", + "type": { + "type": "f32" + } + }, + { + "name": "max_rewind_ms", + "type": { + "type": "u32" + } + }, + { + "name": "mob_heights", + "type": { + "item": { + "type": "f32" + }, + "type": "list" + } } ], "name": "PitRules" diff --git a/examples/game/client/src/mobpit/client.ts b/examples/game/client/src/mobpit/client.ts index 41c6d379..a5566043 100644 --- a/examples/game/client/src/mobpit/client.ts +++ b/examples/game/client/src/mobpit/client.ts @@ -111,6 +111,59 @@ export const TIERS: Readonly [t.tier, { ...(TIER_LOOK[t.tier] ?? { name: `Tier ${t.tier}`, color: '#9ad16b' }), radius: t.radius, tele: t.tele }]) ); +export type Vec3 = [number, number, number]; + +/** One ranged shot: what the client saw, then the room's verdict. */ +export type ShotRecord = { + id: number; + /** Room-local eye position and unit aim (z up). */ + origin: Vec3; + dir: Vec3; + zone: string; + compensated: boolean; + /** The mob the client saw the arrow hit (undefined: a predicted miss). */ + predicted: number | undefined; + /** Where it met the predicted mob, or where it stops. */ + point: Vec3; + delayMs: number; + verdict?: pit.ShotEvent; +}; + +/** Ranged numbers for the HUD and tests. */ +export type RangedStats = { + shots: number; + predictedHits: number; + confirmedHits: number; + /** Client and room agree (same mob, or both a miss). */ + agreed: number; + resolved: number; + clamped: number; + lastAgeMs: number; +}; + +/** Entry distance of a ray (unit `dir`) into a vertical capsule standing on the floor. */ +export function rayCapsule(origin: Vec3, dir: Vec3, x: number, y: number, radius: number, height: number): number | undefined { + // Horizontal circle first, then the height at the entry. + const flat = Math.hypot(dir[0], dir[1]); + if (flat < 1e-6) return undefined; + const ux = dir[0] / flat; + const uy = dir[1] / flat; + const ox = x - origin[0]; + const oy = y - origin[1]; + const along = ox * ux + oy * uy; + const off2 = ox * ox + oy * oy - along * along; + if (along <= 0 || off2 > radius * radius) return undefined; + const entry = (along - Math.sqrt(radius * radius - off2)) / flat; + const z = origin[2] + dir[2] * entry; + return z >= 0 && z <= height ? entry : undefined; +} + +/** Unit aim from yaw (radians from +x) and pitch (up positive). */ +export function aimDir(yaw: number, pitch: number): Vec3 { + const flat = Math.cos(pitch); + return [Math.cos(yaw) * flat, Math.sin(yaw) * flat, Math.sin(pitch)]; +} + /** One leaderboard row (`/dev/mobpit/leaderboard`). */ export type BoardRow = Readonly<{ rank: number; member: string; name: string; score: number }>; /** The leaderboard snapshot the services keep (top-N, store, rebuilds). */ @@ -195,6 +248,17 @@ export type MobPitClient = pit.MobpitClient & pures: ReturnType; /** Resolves on the first hello, once the predicted step is loaded. */ ready: Promise; + /** Ranged shots by id (latest 256). */ + shots: Map; + rangedStats: RangedStats; + /** + * Prepare a ranged shot along `yaw`/`pitch` from the own eye: the + * predicted hit (ray against the mobs as rendered) and the input + * fields to send this frame (shot id, view delay, the fire bits). + */ + aimShot(yaw: number, pitch: number, compensated: boolean, firstPerson?: boolean): { record: ShotRecord; input: Pick } | undefined; + /** Resolves with the verdict of shot `id`. */ + verdict(id: number): Promise; /** The neighbour rooms watched (`watchZones` over `neighbourRooms`), when enabled. */ neighbours(): ReadonlyMap; /** Stop watching neighbours and close the client. */ @@ -247,6 +311,68 @@ export function createMobPitClient(options: MobPitClientOptions): MobPitClient { }); typed.on('saved', (saved) => unsaved.delete(saved.key)); const pures = pit.createMobpitPures(); + + // ---- ranged: predicted hits, verdicts ---- + const shots = new Map(); + const waiters = new Map void>(); + const rangedStats: RangedStats = { shots: 0, predictedHits: 0, confirmedHits: 0, agreed: 0, resolved: 0, clamped: 0, lastAgeMs: 0 }; + let nextShot = 0; + typed.on('shot', (verdict) => { + const shot = shots.get(verdict.shot); + if (shot === undefined || shot.verdict !== undefined) return; + shot.verdict = verdict; + rangedStats.resolved += 1; + if (verdict.hit !== undefined) rangedStats.confirmedHits += 1; + if (verdict.hit === shot.predicted) rangedStats.agreed += 1; + if (verdict.clamped) rangedStats.clamped += 1; + rangedStats.lastAgeMs = verdict.ageMs; + waiters.get(verdict.shot)?.(verdict); + waiters.delete(verdict.shot); + }); + const heights = pit.PIT_RULES.mobHeights; + const range = pit.PIT_RULES.range; + const aimShot = (yaw: number, pitch: number, compensated: boolean, firstPerson = false) => { + const zone = typed.client.authority; + const meNow = me(); + if (zone === undefined || meNow === undefined || !zone.connection.canSendInput()) return undefined; + const p = pit.playerFields(meNow); + // First person: from the eye along the look; else level at the hip. + const origin: Vec3 = [p.x, p.y, p.z + (firstPerson ? pit.PIT_RULES.eyeHeight : pit.PIT_RULES.hipHeight)]; + const dir = aimDir(yaw, firstPerson ? pitch : 0); + // The view time: the room rewinds to exactly what is drawn. + const context = typed.client.inputContext(zone)!; + const renderTime = zone.connection.serverNow() - context.interpolationDelayMs; + const delayMs = Math.max(0, zone.connection.tickTime(context.clientTick) - renderTime); + let predicted: number | undefined; + let best = range; + for (const e of typed.client.render()) { + if (!pit.isMob(e) || e.stale || e.zoneId !== zone.zoneId) continue; + const m = pit.mobFields(e); + const t = TIERS[m.tier]; + const d = rayCapsule(origin, dir, m.x, m.y, t?.radius ?? 0.5, heights[m.tier] ?? 1); + if (d !== undefined && d < best) { + best = d; + predicted = e.id; + } + } + nextShot = (nextShot % 0xffff) + 1; + const record: ShotRecord = { + id: nextShot, + origin, + dir, + zone: zone.zoneId, + compensated, + predicted, + point: [origin[0] + dir[0] * best, origin[1] + dir[1] * best, origin[2] + dir[2] * best], + delayMs + }; + shots.set(record.id, record); + if (shots.size > 256) shots.delete(shots.keys().next().value!); + rangedStats.shots += 1; + if (predicted !== undefined) rangedStats.predictedHits += 1; + const buttons = pit.BUTTON.fire | (compensated ? 0 : pit.BUTTON.noComp) | (firstPerson ? pit.BUTTON.aim : 0); + return { record, input: { buttons, shot: record.id, delayMs: Math.min(0xffff, Math.round(delayMs)), yaw, pitch } }; + }; // The own player: the predicted entity, or (mid-handoff) the SDK's // bridge, the last predicted state in the room just left. const me = () => { @@ -271,6 +397,14 @@ export function createMobPitClient(options: MobPitClientOptions): MobPitClient { pures, ready: Promise.all([hello.ready, typed.prediction?.ready, pures.ready]).then(([h]) => h), neighbours: () => watcher?.states() ?? new Map(), + shots, + rangedStats, + aimShot, + verdict(id) { + const known = shots.get(id)?.verdict; + if (known !== undefined) return Promise.resolve(known); + return new Promise((resolve) => waiters.set(id, resolve)); + }, close: () => { watcher?.stop(); typed.client.close(); diff --git a/examples/game/client/src/mobpit/main.ts b/examples/game/client/src/mobpit/main.ts index 7e001beb..5d622281 100644 --- a/examples/game/client/src/mobpit/main.ts +++ b/examples/game/client/src/mobpit/main.ts @@ -63,6 +63,7 @@ import { roomZone, TIERS, type BoardSnapshot, + type ShotRecord, type RoomInfo, type RoomStatus, type TiersAround @@ -146,13 +147,15 @@ function applySky(dtMs: number): void { const hud = new Hud({ title: 'Mob Pit', - hint: 'WASD move · Space attack · E dash · Q jump (over a slam) · walk through a doorway to the next room · O debug overlay · P prediction · G server ghost. Rooms run on celld; kills are durable facts.', + hint: 'WASD move · Space attack · R / click shoot · E dash · Q jump (over a slam) · V first person · C lag compensation · walk through a doorway to the next room · O debug overlay · P prediction · G server ghost. Rooms run on celld; kills are durable facts.', client, devApi: api }); let debug = params.get('debug') !== '0'; hud.toggle('Debug overlay (celld node per room)', debug, (on) => (debug = on), 'o'); hud.toggle('Client prediction', true, (on) => client.setPredictionEnabled(on), 'p'); +hud.toggle('First-person view (click to look, R / click fires)', false, (on) => stage.setView(on ? 'first' : 'third'), 'v'); +hud.toggle('Lag compensation (ranged)', true, (on) => (compensated = on), 'c'); let ghostOn = true; hud.toggle('Server ghost', true, (on) => (ghostOn = on), 'g'); let corrections = 0; @@ -760,17 +763,74 @@ function drawGraph(): void { const sampler = new InputSampler(); const commandFrames = new InputPacer({ tickRate: () => client.authority?.connection.welcome?.tickRate }); +// Ranged: R or a click fires along the view (first person: where you +// look; third person: the camera's heading). The room resolves the arrow +// lag-compensated (C toggles it per shot). +let fireQueued = false; +let lastFireAt = 0; +let compensated = true; +window.addEventListener('keydown', (e) => { + if (e.code === 'KeyR' && !e.repeat) fireQueued = true; +}); +canvas.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; + // First person: the first click only captures the mouse. + if (stage.view() === 'first' && document.pointerLockElement !== canvas) return; + fireQueued = true; +}); + function sendCommandFrame(): void { const s = sampler.sample(); const move = cameraRelative(s, stage.cameraForward()); - const input: PitInput = { + const aim = stage.aim(); + const first = stage.view() === 'first'; + let input: PitInput = { mx: toAxis(move.x), my: toAxis(move.y), - buttons: (s.action ? BUTTON.attack : 0) | (s.alt ? BUTTON.dash : 0) | (s.jump ? BUTTON.jump : 0) + buttons: (s.action ? BUTTON.attack : 0) | (s.alt ? BUTTON.dash : 0) | (s.jump ? BUTTON.jump : 0) | (first ? BUTTON.aim : 0), + yaw: aim.yaw, + pitch: aim.pitch, + shot: 0, + delayMs: 0 }; + const now = performance.now(); + if (fireQueued && now - lastFireAt >= (PIT_RULES.fireCooldown * 1000) / 30) { + const shot = pit.aimShot(aim.yaw, aim.pitch, compensated, first); + if (shot !== undefined) { + input = { ...input, ...shot.input, buttons: input.buttons | shot.input.buttons }; + lastFireAt = now; + marker(shot.record); + } + } + fireQueued = false; client.sendInput(input); } +// ---- shot markers: yellow where you saw it land, then green (the room +// confirmed the hit) or red (it missed) ---- +const markerMats = { + pending: material(scene, 'marker-pending', '#ffd166'), + hit: material(scene, 'marker-hit', '#3ddc84'), + miss: material(scene, 'marker-miss', '#ff5252') +}; +for (const m of Object.values(markerMats)) m.emissiveColor = m.diffuseColor.clone(); +function marker(record: ShotRecord): void { + const ball = MeshBuilder.CreateSphere(`shot-${record.id}`, { diameter: 0.35, segments: 6 }, scene); + ball.material = markerMats.pending; + ball.isPickable = false; + ball.position.copyFrom(placeIn(record.zone, record.point[0], record.point[1], record.point[2])); + const done = () => setTimeout(() => ball.dispose(), 1200); + pit.verdict(record.id).then((v) => { + ball.material = v.hit !== undefined ? markerMats.hit : markerMats.miss; + ball.position.copyFrom(placeIn(record.zone, v.x, v.y, v.z)); + done(); + }); + setTimeout(() => ball.dispose(), 4000); +} +// A crosshair in first person. +const crosshair = div('pit-crosshair', document.body); +crosshair.textContent = '+'; + // ---- frame ---- stage.onFrame((now, dtMs) => { applySky(dtMs); @@ -840,9 +900,12 @@ stage.onFrame((now, dtMs) => { visual.arc.rotation.y = -(face - SWING_HALF); } const label = `${player.name} · L${player.level} · ${player.hp} HP`; - stage.labels.set(entity.key, visual.body, mine ? `${label} (you)` : label, 1.4, mine ? 'me' : ''); - visual.body.setEnabled(visible); - visual.nose.setEnabled(visible && !dead); + // First person: you are the camera (no body, no label of your own). + const hidden = mine && stage.view() === 'first'; + if (hidden) stage.labels.delete(entity.key); + else stage.labels.set(entity.key, visual.body, mine ? `${label} (you)` : label, 1.4, mine ? 'me' : ''); + visual.body.setEnabled(visible && !hidden); + visual.nose.setEnabled(visible && !dead && !hidden); if (mine) me = { at: visual.body.position, hp: player.hp, dead }; } } @@ -859,7 +922,8 @@ stage.onFrame((now, dtMs) => { stage.labels.delete(`popup:${key}`); popups.delete(key); } - if (me !== undefined) stage.follow(me.at.x, me.at.z, dtMs); + if (me !== undefined) stage.follow(me.at.x, me.at.z, dtMs, me.at.y - 0.9); + crosshair.style.display = stage.view() === 'first' ? '' : 'none'; // The server's own copy of you (authoritative, behind by the round // trip): a wireframe ghost in the room that owns you. @@ -905,6 +969,8 @@ stage.onFrame((now, dtMs) => { hud.row('World event', w === undefined || w.event === '' ? 'none (the borough schedules the next)' : `${w.event} · ${left} s left`); eventBanner.style.display = w !== undefined && w.event !== '' ? '' : 'none'; if (w !== undefined && w.event !== '') eventBanner.textContent = `${EVENT_TEXT[w.event] ?? w.event} · ${left} s`; + const r = pit.rangedStats; + hud.row('Ranged', `${r.shots} shots · ${r.confirmedHits} hits confirmed · ${r.agreed}/${r.resolved} as seen · last rewind ${r.lastAgeMs.toFixed(0)} ms${compensated ? '' : ' (compensation OFF)'}`); hud.row('Interest', hello === undefined ? '—' : `${hello.viewRadius} m ring · ${rendered.filter((e) => isMob(e) && !e.stale).length} mobs replicated`); const profile = pit.profile(); if (profile !== undefined) { diff --git a/examples/game/client/src/mobpit/style.css b/examples/game/client/src/mobpit/style.css index f548bf88..431037b5 100644 --- a/examples/game/client/src/mobpit/style.css +++ b/examples/game/client/src/mobpit/style.css @@ -193,3 +193,15 @@ pointer-events: none; z-index: 5; } + +.pit-crosshair { + position: fixed; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + color: #fff; + font: 24px/1 monospace; + text-shadow: 0 0 3px #000; + pointer-events: none; + z-index: 6; +} diff --git a/examples/game/client/src/shared/scene.ts b/examples/game/client/src/shared/scene.ts index 8d130149..a0b282b9 100644 --- a/examples/game/client/src/shared/scene.ts +++ b/examples/game/client/src/shared/scene.ts @@ -39,8 +39,10 @@ export type Stage = Readonly<{ setView(view: CameraView): void; /** Ground-plane direction the camera looks along (game x/y), for camera-relative input. */ cameraForward(): { x: number; y: number }; + /** Where the view looks, in game terms: yaw (radians from +x on the floor) and pitch (up positive; 0 in third person). */ + aim(): { yaw: number; pitch: number }; /** Ease the camera target toward a game position (first person: place the eye there). */ - follow(x: number, y: number, dtMs: number): void; + follow(x: number, y: number, dtMs: number, height?: number): void; /** Register a per-frame callback (after input, before render). */ onFrame(callback: (now: number, dtMs: number) => void): void; }>; @@ -169,9 +171,13 @@ export function createStage(canvas: HTMLCanvasElement, options: StageOptions): S } }, cameraForward: forward, - follow(x, y, dtMs) { + aim() { + const f = forward(); + return { yaw: Math.atan2(f.y, f.x), pitch: view === 'first' ? -pitch : 0 }; + }, + follow(x, y, dtMs, height = 0) { if (view === 'first') { - eye.position.set(x, EYE_HEIGHT, y); + eye.position.set(x, EYE_HEIGHT + height, y); // Keep the orbit camera centred so switching back is seamless. camera.setTarget(new Vector3(x, 0, y), false, false, true); return; diff --git a/examples/game/client/tests/mobpit-ranged-headless.test.mjs b/examples/game/client/tests/mobpit-ranged-headless.test.mjs new file mode 100644 index 00000000..b052f731 --- /dev/null +++ b/examples/game/client/tests/mobpit-ranged-headless.test.mjs @@ -0,0 +1,81 @@ +/** + * Headless Mob Pit ranged combat (Node): rooms on their own `celld dev`, + * the services binary, a scripted archer under simulated latency. + * + * The archer aims at mobs *as rendered* (interpolated, behind the room by + * the round trip and the interpolation delay) and looses arrows. With lag + * compensation the room rewinds the mobs to the archer's view time and + * confirms what the archer saw; without it the verdict uses the latest + * state (no rewind). Predicted (client) and confirmed (room) hits are + * compared shot by shot. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { isMob, mobFields, PIT_RULES, playerFields } from '../src/generated/mobpit/index.ts'; +import { createMobPitClient } from '../src/mobpit/client.ts'; +import { DevApi } from '../src/shared/dev-api.ts'; +import { memoryStorage, sleep, until } from './helpers/demo-server.mjs'; +import { startPit } from './helpers/mobpit.mjs'; + +/** Loose `n` arrows at the nearest rendered mob, each when the client sees a clear hit. */ +async function volley(pit, n, compensated) { + const records = []; + const deadline = Date.now() + 60_000; + while (records.length < n) { + assert.ok(Date.now() < deadline, 'volley'); + await sleep(1000 / 30); + const me = pit.me(); + if (me === undefined) continue; + const at = playerFields(me); + const mob = pit.client + .render() + .filter((e) => isMob(e) && !e.stale && e.zoneId === pit.room()) + .map((e) => ({ e, m: mobFields(e) })) + .sort((a, b) => Math.hypot(a.m.x - at.x, a.m.y - at.y) - Math.hypot(b.m.x - at.x, b.m.y - at.y))[0]; + if (mob === undefined) continue; + const eye = at.z + PIT_RULES.eyeHeight; + const mid = (PIT_RULES.mobHeights[mob.m.tier] ?? 1) / 2; + const d = Math.hypot(mob.m.x - at.x, mob.m.y - at.y); + const yaw = Math.atan2(mob.m.y - at.y, mob.m.x - at.x); + const pitch = Math.atan2(mid - eye, d); + // First person: from the eye, pitched at the mob's middle. + const shot = pit.aimShot(yaw, pitch, compensated, true); + if (shot === undefined) continue; + pit.client.sendInput({ mx: 0, my: 0, ...shot.input }); + if (shot.record.predicted === mob.e.id) records.push(shot.record); + // The bow's cooldown. + await sleep((PIT_RULES.fireCooldown * 1000) / 30 + 20); + } + const verdicts = await Promise.all(records.map((r) => pit.verdict(r.id))); + return records.map((r, i) => ({ predicted: r.predicted, ...verdicts[i] })); +} + +test('arrows: lag compensation confirms what the archer saw', { timeout: 600_000 }, async () => { + const server = await startPit('mobpit'); + const clients = []; + try { + const api = new DevApi({ base: server.httpUrl, name: () => 'Archer', storage: memoryStorage() }); + const pit = createMobPitClient({ api, roomsBase: server.roomsUrl, neighbours: false }); + clients.push(pit); + await pit.client.connect('pit.0.0'); + await pit.ready; + await until(() => pit.client.predictor?.active, 5_000, 'bound'); + await api.setNetSim({ latencyMs: 80, jitterMs: 0, loss: 0 }); + await sleep(2500); + + const on = await volley(pit, 8, true); + const hits = on.filter((v) => v.hit === v.predicted).length; + console.error(`compensated: ${hits}/${on.length} confirmed as seen, rewinds ${on.map((v) => v.ageMs.toFixed(0)).join(' ')} ms`); + assert.ok(hits >= 6, `${hits}/${on.length} compensated arrows confirmed`); + assert.ok(on.every((v) => v.compensated && v.ageMs > 60), 'rewound to the view time'); + assert.ok(pit.rangedStats.agreed >= hits); + + const off = await volley(pit, 4, false); + console.error(`uncompensated: ${off.filter((v) => v.hit === v.predicted).length}/${off.length} confirmed`); + assert.ok(off.every((v) => !v.compensated && v.ageMs === 0), 'no rewind without compensation'); + } finally { + for (const c of clients) c.close(); + await server.stop(); + } +}); diff --git a/examples/game/client/vite.config.ts b/examples/game/client/vite.config.ts index 672b4b6a..c356a119 100644 --- a/examples/game/client/vite.config.ts +++ b/examples/game/client/vite.config.ts @@ -7,7 +7,7 @@ import { resolve } from 'node:path'; import { defineConfig } from 'vite'; -const DEMOS = ['mobpit', 'leaderboard', 'gallery', 'freeze']; +const DEMOS = ['mobpit', 'leaderboard', 'freeze']; const server = process.env.GAME_DEMOS_SERVER ?? 'http://127.0.0.1:2567'; const rooms = process.env.GAME_DEMOS_ROOMS ?? 'http://127.0.0.1:2570'; diff --git a/examples/game/run.sh b/examples/game/run.sh index f6c1b29d..92301eb3 100755 --- a/examples/game/run.sh +++ b/examples/game/run.sh @@ -60,13 +60,13 @@ SVELTEKIT="$HERE/sveltekit" BIN="$SERVER/target/debug/game-demos" SERVICE_BIN="$SERVER/target/debug/mobpit-service" CELLS="$HERE/cells" -DEMOS=(mobpit leaderboard gallery freeze) +DEMOS=(mobpit leaderboard freeze) # Demos whose durable state lives in celld cells. CELLD_DEMOS=(mobpit leaderboard freeze) export MOBPIT_DATA_DIR="${MOBPIT_DATA_DIR:-$HERE/.data/mobpit}" # Game surfaces with a generated client (`game_demos::::game_surface`); # the leaderboard page uses Mob Pit's. -SURFACES=(mobpit gallery freeze) +SURFACES=(mobpit freeze) CLI="$REPO/target/debug/distributed" log() { printf '\033[1;36m[demos]\033[0m %s\n' "$*"; } diff --git a/examples/game/server/rooms/src/celld.rs b/examples/game/server/rooms/src/celld.rs index 79827f5c..2059d883 100644 --- a/examples/game/server/rooms/src/celld.rs +++ b/examples/game/server/rooms/src/celld.rs @@ -26,6 +26,14 @@ impl CelldZone for MobPit { // Joined / lost / reconnected / left facts for the presence cells. lifecycle_facts: true, max_players: 32, + // A doorway into a sleeping room waits for it to wake while the + // client keeps predicting: keep up to 3 s of its inputs. + input_capacity: 90, + // Ranged shots are rewound at most this far (lag compensation). + rewind: distributed_game::RewindConfig { + max_rewind: std::time::Duration::from_millis(crate::zone::MAX_REWIND_MS), + ..distributed_game::RewindConfig::default() + }, ..HostConfig::default() } } diff --git a/examples/game/server/rooms/src/zone.rs b/examples/game/server/rooms/src/zone.rs index 38ba5314..6c4202d3 100644 --- a/examples/game/server/rooms/src/zone.rs +++ b/examples/game/server/rooms/src/zone.rs @@ -50,6 +50,7 @@ use std::collections::{BTreeMap, HashMap, VecDeque}; use std::time::Duration; +use distributed_game::rewind::{Historic, HitQuery, RewindFrame, ShotTiming}; use distributed_game::{ schema, ConsumePolicy, Ctx, EmitError, Entities, Entity, EntityId, EntitySchema, Fact, FactOutcome, FactSettlement, GameEvent, GameInput, GameRecord, GameSurface, LeaveReason, @@ -136,6 +137,9 @@ pub struct PlayerView { /// Waiting to respawn. #[correct(replace)] pub dead: bool, + /// Bow animation frames left (a ranged shot was loosed). + #[correct(replace)] + pub shot: u8, } /// A mob (dead mobs are not replicated). @@ -336,6 +340,28 @@ pub enum PitEvent { /// whenever it changes. #[game_event(id = 7)] World(WorldTiers), + /// The verdict of one ranged shot (to the shooter). + #[game_event(id = 9)] + Shot { + /// Client shot id. + shot: u16, + /// Mob hit. + hit: Option, + /// Lag compensation used. + compensated: bool, + /// Rewind applied (ms, 0 without compensation). + age_ms: f32, + /// Rewind the shooter asked for (ms). + requested_age_ms: f32, + /// Limited by the clamp or the stored history. + clamped: bool, + /// Where the arrow stopped (room-local). + x: f32, + /// Where the arrow stopped. + y: f32, + /// Where the arrow stopped (height). + z: f32, + }, /// The hood's boss fell here. #[game_event(id = 8)] BossDown { @@ -448,6 +474,31 @@ const RECOVER_TICKS: u16 = 18; const RESPAWN_TICKS: u64 = 120; /// A player at least this high (m) jumps over a mob's slam. pub const SLAM_CLEARANCE: f32 = 0.9; +/// Ranged reach (m). +pub const RANGE: f32 = 30.0; +/// Damage per arrow. +pub const RANGED_DAMAGE: i32 = 12; +/// Ticks between shots. +pub const FIRE_COOLDOWN: u8 = 12; +/// Bow animation frames. +const SHOT_FRAMES: u8 = 6; +/// Eye height above the feet (m): where first-person arrows leave from. +pub const EYE_HEIGHT: f32 = 1.5; +/// Where third-person (and top-down) arrows leave from, flying level (m). +pub const HIP_HEIGHT: f32 = 0.6; +/// Furthest a shot is rewound (ms): the anti-cheat clamp. +pub const MAX_REWIND_MS: u64 = 600; +/// Obstacles stop arrows below this height (m). +const OBSTACLE_HEIGHT: f32 = 2.4; +/// Mob body height by tier (m): their hit capsule. +fn mob_height(tier: u8) -> f32 { + match tier { + 4 => 2.8, + 3 => 2.0, + 2 => 1.5, + _ => 0.9, + } +} const PLAYER_RESPAWN_TICKS: u64 = 90; /// Mob tier stats: (max hp, speed m/s, damage, radius, telegraph radius). @@ -506,6 +557,18 @@ pub struct PitRules { pub attack_half_angle: f32, /// Swing animation length (ticks; the player's `swing` counts down from it). pub swing_frames: u8, + /// Ranged reach (m). + pub range: f32, + /// Ticks between shots. + pub fire_cooldown: u8, + /// Eye height above the feet (m): first-person arrows. + pub eye_height: f32, + /// Hip height (m): level third-person arrows. + pub hip_height: f32, + /// Furthest a shot is rewound (ms). + pub max_rewind_ms: u32, + /// Mob body height by tier (index = tier). + pub mob_heights: Vec, } /// The `PIT_RULES` constant. @@ -522,6 +585,12 @@ pub fn pit_rules() -> PitRules { attack_range: ATTACK_RANGE, attack_half_angle: ATTACK_ARC_COS.acos(), swing_frames: SWING_FRAMES, + range: RANGE, + fire_cooldown: FIRE_COOLDOWN, + eye_height: EYE_HEIGHT, + hip_height: HIP_HEIGHT, + max_rewind_ms: MAX_REWIND_MS as u32, + mob_heights: (0..=4).map(mob_height).collect(), } } @@ -566,6 +635,9 @@ struct Player { dead_until: Option, level: u32, regen: u8, + /// Ranged cooldown (ticks) and bow animation frames. + fire_cd: u8, + shot_anim: u8, /// Handed to a neighbouring room: no more steps here. leaving: bool, } @@ -658,6 +730,19 @@ pub struct MobPit { world: WorldTiers, /// This activation told its borough it is awake (arms its events). awake_reported: bool, + /// Ranged shots of this tick, resolved after the players stepped. + fires: Vec, +} + +/// A ranged shot to resolve. +struct Fire { + player: PlayerId, + shot: u16, + client_tick: u32, + delay_ms: u16, + origin: [f32; 3], + dir: [f32; 3], + compensated: bool, } fn spawn_state(player: PlayerId) -> MoveState { @@ -927,6 +1012,7 @@ impl MobPit { fn step_players(&mut self, ctx: &mut Ctx<'_, Self>) -> Vec<(PlayerId, f32, f32, f32)> { let tick = ctx.tick(); let mut swings = Vec::new(); + let mut fires = Vec::new(); // The predicted step's context: this room (its seeded obstacles). let context = PitRoomContext { seed: self.room.seed, @@ -937,6 +1023,8 @@ impl MobPit { for (id, p) in &mut self.players { p.credits = (p.credits + 1).min(MAX_CREDITS); p.swing_cd = p.swing_cd.saturating_sub(1); + p.fire_cd = p.fire_cd.saturating_sub(1); + p.shot_anim = p.shot_anim.saturating_sub(1); p.swing_anim = p.swing_anim.saturating_sub(1); if let Some(until) = p.dead_until { // Dead: inputs are consumed but ignored. @@ -960,14 +1048,45 @@ impl MobPit { let Some(input) = ctx.take_inputs(*id, ConsumePolicy::Next).pop() else { break; }; + let client_tick = input.client_tick; let input = input.input; // The declared predicted step: what the client runs as WASM. p.state = pit_step(&p.state, &input, &context, movement::DT); p.credits -= 1; let (sx, sy) = (f32::from(input.mx), f32::from(input.my)); - if sx * sx + sy * sy > 20.0 * 20.0 { + if input.aims() && input.yaw.is_finite() { + // First person: you face where you look. + p.face = input.yaw; + } else if sx * sx + sy * sy > 20.0 * 20.0 { p.face = sy.atan2(sx); } + if input.fire() && p.fire_cd == 0 { + p.fire_cd = FIRE_COOLDOWN; + p.shot_anim = SHOT_FRAMES; + fires.push(Fire { + player: *id, + shot: input.shot, + client_tick, + delay_ms: input.delay_ms, + // First person: from the eye along the look; else + // level, at waist height, along the yaw. + origin: [ + p.state.x, + p.state.y, + p.state.z + if input.aims() { EYE_HEIGHT } else { HIP_HEIGHT }, + ], + dir: if input.aims() { + input.aim_dir() + } else { + PitInput { + pitch: 0.0, + ..input + } + .aim_dir() + }, + compensated: input.buttons & PitInput::NO_COMP == 0, + }); + } if input.attack() && p.swing_cd == 0 { p.swing_cd = SWING_COOLDOWN; p.swing_anim = SWING_FRAMES; @@ -986,6 +1105,7 @@ impl MobPit { } ctx.set_view(*id, View::new(p.state.x, p.state.y, VIEW_RADIUS)); } + self.fires = fires; swings } @@ -1038,25 +1158,126 @@ impl MobPit { if cos < ATTACK_ARC_COS && d > radius + PLAYER_RADIUS { continue; } - m.hp -= ATTACK_DAMAGE; - if matches!(m.state, MobState::Idle | MobState::Return) { - m.state = MobState::Chase; - m.target = Some(player); - } - if m.hp <= 0 { - m.hp = 0; - m.state = MobState::Dead; - m.respawn_at = ctx.tick() - + if m.tier == BOSS_TIER { - BOSS_RESPAWN_TICKS - } else { - RESPAWN_TICKS - }; - m.target = None; - self.emit_kill(ctx, player, i); - } + self.hurt_mob(ctx, player, i, ATTACK_DAMAGE); + } + } + } + + /// `player` deals `damage` to mob `i` (it turns on them; at 0 HP it + /// dies and the kill is a fact). + fn hurt_mob(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId, i: usize, damage: i32) { + let m = &mut self.mobs[i]; + m.hp -= damage; + if matches!(m.state, MobState::Idle | MobState::Return) { + m.state = MobState::Chase; + m.target = Some(player); + } + if m.hp <= 0 { + m.hp = 0; + m.state = MobState::Dead; + m.respawn_at = ctx.tick() + + if m.tier == BOSS_TIER { + BOSS_RESPAWN_TICKS + } else { + RESPAWN_TICKS + }; + m.target = None; + self.emit_kill(ctx, player, i); + } + } + + /// Resolve this tick's ranged shots, **lag-compensated**: each is a ray + /// from the shooter's eye (its jump height included) along its aim, + /// tested against the mobs as they were at the shooter's view time + /// (`client_tick` minus the interpolation delay the shot carries, + /// `ctx.rewind()`, clamped to `MAX_REWIND_MS`), or against the latest + /// state with compensation off. Obstacles stop arrows. Each shooter + /// gets a verdict ([`PitEvent::Shot`]). + fn resolve_fires(&mut self, ctx: &mut Ctx<'_, Self>) { + let fires = std::mem::take(&mut self.fires); + let mut verdicts = Vec::with_capacity(fires.len()); + { + let rewind = ctx.rewind(); + let Some(latest) = rewind.latest().map(|f| f.time()) else { + return; + }; + for fire in &fires { + let query = HitQuery::ray(fire.origin, fire.dir, RANGE); + let timing = ShotTiming { + client_tick: fire.client_tick, + interp_delay: Duration::from_millis(fire.delay_ms.into()), + }; + let resolved = rewind.resolve(timing); + let (time, age, requested, clamped) = match (fire.compensated, resolved) { + (true, Some(r)) => (r.time, r.age, r.requested_age, r.clamped), + (_, r) => ( + latest, + Duration::ZERO, + r.map_or(Duration::ZERO, |r| r.requested_age), + false, + ), + }; + let mobs = &self.mobs; + let hit = rewind + .hit_test(time, &query, |id| { + mobs.get(id as usize - 1) + .is_some_and(|m| m.state != MobState::Dead) + }) + .map(|h| (h.id, h.distance)) + .filter(|&(_, d)| d < self.blocked_at(fire.origin, fire.dir)); + verdicts.push((hit, time, age, requested, clamped)); } } + for (fire, (hit, _, age, requested, clamped)) in fires.into_iter().zip(verdicts) { + let distance = hit.map_or(RANGE.min(self.blocked_at(fire.origin, fire.dir)), |h| h.1); + let at = [ + fire.origin[0] + fire.dir[0] * distance, + fire.origin[1] + fire.dir[1] * distance, + fire.origin[2] + fire.dir[2] * distance, + ]; + if let Some((id, _)) = hit { + self.hurt_mob(ctx, fire.player, id as usize - 1, RANGED_DAMAGE); + } + ctx.send( + fire.player, + &PitEvent::Shot { + shot: fire.shot, + hit: hit.map(|h| h.0), + compensated: fire.compensated, + age_ms: age.as_secs_f32() * 1000.0, + requested_age_ms: requested.as_secs_f32() * 1000.0, + clamped: fire.compensated && clamped, + x: at[0], + y: at[1], + z: at[2], + }, + ); + } + } + + /// Distance along a ray to the first obstacle it meets below its top + /// (a column, boulder or tree), or the range. + fn blocked_at(&self, origin: [f32; 3], dir: [f32; 3]) -> f32 { + let flat = (dir[0] * dir[0] + dir[1] * dir[1]).sqrt(); + if flat < 1e-4 { + return RANGE; + } + let (ux, uy) = (dir[0] / flat, dir[1] / flat); + let mut best = RANGE; + for o in &self.pillars { + let (ox, oy) = (o.x - origin[0], o.y - origin[1]); + let along = ox * ux + oy * uy; + let off2 = ox * ox + oy * oy - along * along; + if along <= 0.0 || off2 > o.r * o.r { + continue; + } + // Horizontal distance to the entry, then along the 3D ray. + let entry = (along - (o.r * o.r - off2).sqrt()) / flat; + if (0.0..best).contains(&entry) && origin[2] + dir[2] * entry < OBSTACLE_HEIGHT { + best = entry; + } + } + best } fn step_mobs(&mut self, ctx: &mut Ctx<'_, Self>) { @@ -1280,6 +1501,8 @@ impl MobPit { dead_until: None, level: 1, regen: 0, + fire_cd: 0, + shot_anim: 0, leaving: false, }, ); @@ -1390,6 +1613,7 @@ impl Zone for MobPit { } }, awake_reported: false, + fires: Vec::new(), } } @@ -1472,6 +1696,9 @@ impl Zone for MobPit { } self.measure_tick(ctx.now()); let swings = self.step_players(ctx); + // Shots first, against the history up to the previous tick (what + // the shooters were shown), then the melee swings. + self.resolve_fires(ctx); self.resolve_swings(ctx, swings); self.step_mobs(ctx); self.walk_through_doorways(ctx); @@ -1609,6 +1836,19 @@ impl Zone for MobPit { } } + /// The mobs' hit capsules each tick (ranged lag compensation). + fn record_rewind(&self, frame: &mut RewindFrame) { + for m in self.mobs.iter().filter(|m| m.state != MobState::Dead) { + let half = mob_height(m.tier) / 2.0; + frame.push(Historic::capsule( + m.id.get(), + [m.x, m.y, half], + half, + tier_stats(m.tier).3, + )); + } + } + fn has_pending_work(&self) -> bool { !self.backlog.is_empty() } @@ -1692,6 +1932,7 @@ impl Project for Player { swing: self.swing_anim, level: self.level.min(u32::from(u16::MAX)) as u16, dead: self.dead_until.is_some(), + shot: self.shot_anim, }, )) } @@ -1823,6 +2064,7 @@ pub fn encode_bot_input(dir: [f32; 2]) -> Vec { mx: axis(dir[0]), my: axis(dir[1]), buttons: PitInput::ATTACK, + ..PitInput::default() } .to_payload() } diff --git a/examples/game/server/rooms/tests/rooms.rs b/examples/game/server/rooms/tests/rooms.rs index c08263e2..39030122 100644 --- a/examples/game/server/rooms/tests/rooms.rs +++ b/examples/game/server/rooms/tests/rooms.rs @@ -102,6 +102,7 @@ fn a_room_greets_with_its_coordinates_and_hands_off_through_doorways() { mx: (dx / len * 127.0) as i8, my: (dy / len * 127.0) as i8, buttons: 0, + ..PitInput::default() }; room.push_input( client, @@ -225,6 +226,7 @@ fn walk( mx: (dir[0] * 127.0) as i8, my: (dir[1] * 127.0) as i8, buttons: 0, + ..PitInput::default() }; room.push_input( client, @@ -378,6 +380,7 @@ fn a_boss_falls_to_a_party_its_hood_hears_and_it_stays_down() { mx: (dx * k).round().clamp(-127.0, 127.0) as i8, my: (dy * k).round().clamp(-127.0, 127.0) as i8, buttons: if near { PitInput::ATTACK } else { 0 }, + ..PitInput::default() }; room.push_input( *client, @@ -504,6 +507,7 @@ fn face_a_slam(jump: bool) -> (i32, i32) { mx: (dx * k) as i8, my: (dy * k) as i8, buttons: 0, + ..PitInput::default() }, ); } @@ -525,3 +529,101 @@ fn a_jump_clears_a_mobs_slam() { "jumping over it does not: {before} → {after}" ); } + +#[test] +fn an_arrow_hits_the_mob_it_was_aimed_at_and_obstacles_stop_it() { + let clock = ManualClock::new(); + let mut room = host("pit.0.0", &clock); + let (client, player) = join(&mut room, "ana"); + for _ in 0..3 { + tick(&mut room, &clock); + } + let me = room.zone().player_state(player).expect("in").0; + // The nearest living mob with nothing in between. + let (id, mx, my, _) = room + .zone() + .mob_positions() + .into_iter() + .filter(|m| m.3) + .min_by(|a, b| { + let d = |m: &(u32, f32, f32, bool)| (m.1 - me.x).powi(2) + (m.2 - me.y).powi(2); + d(a).total_cmp(&d(b)) + }) + .expect("a mob"); + let yaw = (my - me.y).atan2(mx - me.x); + let d = ((mx - me.x).powi(2) + (my - me.y).powi(2)).sqrt(); + // Aim at the mob's middle from the eye. + let pitch = ((0.45 - mobpit_rooms::EYE_HEIGHT) / d).atan(); + let shot = |n: u16, flags: u8| PitInput { + buttons: PitInput::FIRE | flags, + yaw, + pitch, + shot: n, + delay_ms: 100, + ..PitInput::default() + }; + let mut verdicts = Vec::new(); + for (seq, flags) in [(1u32, 0u8), (2, PitInput::NO_COMP)] { + room.push_input( + client, + seq, + room.tick() as u32, + &distributed_game::GameInput::to_payload(&shot(seq as u16, flags)), + ); + for _ in 0..14 { + let out = tick(&mut room, &clock); + verdicts.extend(events(&out).into_iter().filter_map(|e| match e { + PitEvent::Shot { + shot, + hit, + compensated, + age_ms, + .. + } => Some((shot, hit, compensated, age_ms)), + _ => None, + })); + } + } + assert_eq!(verdicts.len(), 2, "{verdicts:?}"); + assert_eq!(verdicts[0].1, Some(id), "compensated: hit {verdicts:?}"); + assert!( + verdicts[0].2 && verdicts[0].3 > 50.0, + "rewound ~100 ms: {verdicts:?}" + ); + assert!( + !verdicts[1].2 && verdicts[1].3 == 0.0, + "uncompensated: no rewind" + ); + // Straight into the central pillar (0, 0, r 2.5): stopped short. + let into_pillar = PitInput { + buttons: PitInput::FIRE, + yaw: (0.0 - me.y).atan2(0.0 - me.x), + pitch: 0.0, + shot: 9, + ..PitInput::default() + }; + room.push_input( + client, + 3, + room.tick() as u32, + &distributed_game::GameInput::to_payload(&into_pillar), + ); + let mut stopped = None; + for _ in 0..3 { + let out = tick(&mut room, &clock); + for e in events(&out) { + if let PitEvent::Shot { + shot: 9, hit, x, y, .. + } = e + { + stopped = Some((hit, (x * x + y * y).sqrt())); + } + } + } + let (hit, from_centre) = stopped.expect("verdict"); + assert_eq!(hit, None); + assert!( + (from_centre - 2.5).abs() < 0.2, + "stopped at the pillar: {from_centre}" + ); +} diff --git a/examples/game/server/sim/src/mobpit.rs b/examples/game/server/sim/src/mobpit.rs index e3e41aa8..d2ee99a5 100644 --- a/examples/game/server/sim/src/mobpit.rs +++ b/examples/game/server/sim/src/mobpit.rs @@ -26,9 +26,13 @@ use distributed_game::{game_pure, predict, FieldType, GameInput, GameRecord}; use crate::arena::{self, MoveInput, MoveState}; use crate::grid; -/// Input: the Arena's 3-byte frame (`[mx, my, buttons]`) with attack and -/// jump bits next to dash. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, GameInput)] +/// Input: the Arena's frame (`[mx, my, buttons]`) with attack, jump, fire, +/// no-compensation and aim bits next to dash, then the aim — the yaw (the +/// facing in the room's plane, radians from +x) and pitch (up positive) the +/// player looks along — and, for a ranged shot, its id and the view delay +/// behind `client_tick` the shooter was rendering (lag compensation). 11 +/// bytes per command frame. +#[derive(Debug, Clone, Copy, Default, PartialEq, GameInput)] pub struct PitInput { /// Stick x. #[range(min = -127, max = 127)] @@ -36,9 +40,22 @@ pub struct PitInput { /// Stick y. #[range(min = -127, max = 127)] pub my: i8, - /// [`PitInput::DASH`] | [`PitInput::ATTACK`] | [`PitInput::JUMP`]. - #[buttons(dash, attack, jump)] + /// [`PitInput::DASH`] | [`PitInput::ATTACK`] | [`PitInput::JUMP`] | + /// [`PitInput::FIRE`] (a ranged shot) | [`PitInput::NO_COMP`] (resolve it + /// without lag compensation) | [`PitInput::AIM`] (face the aim: first + /// person). + #[buttons(dash, attack, jump, fire, no_comp, aim)] pub buttons: u8, + /// Aim yaw (radians from +x in the room's plane). + #[quant(centered = 3.2, bits = 16)] + pub yaw: f32, + /// Aim pitch (radians, up positive). + #[quant(centered = 1.6, bits = 16)] + pub pitch: f32, + /// Client shot id (with [`PitInput::FIRE`]; echoed in the verdict). + pub shot: u16, + /// Interpolation delay behind `client_tick` the shooter was rendering (ms). + pub delay_ms: u16, } impl PitInput { @@ -60,6 +77,31 @@ impl PitInput { pub fn jump(self) -> bool { self.buttons & Self::JUMP != 0 } + + /// A ranged shot this frame. + pub fn fire(self) -> bool { + self.buttons & Self::FIRE != 0 + } + + /// Face the aim (first person). + pub fn aims(self) -> bool { + self.buttons & Self::AIM != 0 + } + + /// The unit aim direction `[x, y, z]` (z up), from yaw and pitch. + /// Server-side only (trig is not part of the predicted step). + pub fn aim_dir(self) -> [f32; 3] { + let (yaw, pitch) = ( + if self.yaw.is_finite() { self.yaw } else { 0.0 }, + if self.pitch.is_finite() { + self.pitch.clamp(-1.5, 1.5) + } else { + 0.0 + }, + ); + let flat = pitch.cos(); + [yaw.cos() * flat, yaw.sin() * flat, pitch.sin()] + } } /// Gravity (m/s²). @@ -904,8 +946,7 @@ mod tests { s, PitInput { mx: 127, - my: 0, - buttons: 0, + ..PitInput::default() }, &circles, ); @@ -920,9 +961,8 @@ mod tests { #[test] fn a_jump_goes_up_and_lands() { let jump = PitInput { - mx: 0, - my: 0, buttons: PitInput::JUMP, + ..PitInput::default() }; let mut s = canonicalize(PitState { x: 4.5, @@ -966,7 +1006,14 @@ mod tests { fn walk(mut s: PitState, mx: i8, my: i8, frames: usize) -> PitState { for _ in 0..frames { - s = step(s, PitInput { mx, my, buttons: 0 }); + s = step( + s, + PitInput { + mx, + my, + ..PitInput::default() + }, + ); } s } diff --git a/examples/game/server/src/common/mod.rs b/examples/game/server/src/common/mod.rs index 4964294b..732c0704 100644 --- a/examples/game/server/src/common/mod.rs +++ b/examples/game/server/src/common/mod.rs @@ -28,7 +28,7 @@ pub use guests::{random_hex, Guests}; pub trait Demo: 'static { /// The zone type. type Zone: Zone; - /// Subcommand / URL path name (`gallery` → `/gallery/`). + /// Subcommand / URL path name (`freeze` → `/freeze/`). const NAME: &'static str; /// Zone the client joins by default. const DEFAULT_ZONE: &'static str; diff --git a/examples/game/server/src/lib.rs b/examples/game/server/src/lib.rs index e4c91db9..6bba7f57 100644 --- a/examples/game/server/src/lib.rs +++ b/examples/game/server/src/lib.rs @@ -15,6 +15,5 @@ pub mod common; pub mod devcelld; pub mod freeze; -pub mod gallery; pub mod mobpit; pub mod predict_trace; diff --git a/examples/game/server/src/main.rs b/examples/game/server/src/main.rs index 11092851..56f16883 100644 --- a/examples/game/server/src/main.rs +++ b/examples/game/server/src/main.rs @@ -1,9 +1,7 @@ //! `game-demos [--port N] [--dev]` — one subcommand per demo. use clap::{Parser, Subcommand}; -use game_demos::common::{run, DemoArgs}; use game_demos::freeze::world::FreezeArgs; -use game_demos::gallery::GalleryDemo; use game_demos::mobpit::{self, MobPitArgs}; #[derive(Parser)] @@ -22,8 +20,6 @@ enum DemoCommand { Mobpit(MobPitArgs), /// Kills/XP leaderboards (same server as `mobpit`). Leaderboard(MobPitArgs), - /// Lag compensation: hitscan shots rewound to the shooter's view time. - Gallery(DemoArgs), /// World-version barrier across two server processes, safe-room reloads. Freeze(FreezeArgs), /// Print native traces of every predicted step over recorded input @@ -36,7 +32,6 @@ enum DemoCommand { async fn main() -> std::io::Result<()> { match Cli::parse().demo { DemoCommand::Mobpit(args) | DemoCommand::Leaderboard(args) => mobpit::run(args).await, - DemoCommand::Gallery(args) => run::(args).await, DemoCommand::Freeze(args) => game_demos::freeze::world::run(args).await, DemoCommand::PredictTrace => { println!("{}", game_demos::predict_trace::traces()); diff --git a/examples/game/server/src/predict_trace.rs b/examples/game/server/src/predict_trace.rs index bf20984e..a2f8a3ba 100644 --- a/examples/game/server/src/predict_trace.rs +++ b/examples/game/server/src/predict_trace.rs @@ -161,6 +161,31 @@ fn capsule_streams(extra_bytes: usize) -> Vec { /// Mob Pit's step in the entrance (the classic pillars) and in rooms of /// other seeded patterns (the context names the room; its obstacles are /// generated inside the step). +/// Mob Pit frames from `[mx, my, buttons]` frames: the aim and shot +/// fields (varint-encoded) filled from a seeded generator, every button bit +/// kept (unknown ones are cleared by the decoder). +fn pit_frames(frames: Vec>, seed: u32) -> Vec> { + use distributed_game::GameInput; + use game_sim::mobpit::PitInput; + let mut rng = Rng(0x7a11_0000 ^ seed.max(1)); + frames + .into_iter() + .map(|f| { + let unit = |r: &mut Rng| (r.next() >> 8) as f32 / 16_777_216.0; + PitInput { + mx: f[0] as i8, + my: f[1] as i8, + buttons: f[2], + yaw: (unit(&mut rng) - 0.5) * 6.2, + pitch: unit(&mut rng) * 3.0 - 1.5, + shot: rng.next() as u16, + delay_ms: (rng.next() % 900) as u16, + } + .to_payload() + }) + .collect() +} + fn pit_streams() -> Vec { use game_sim::mobpit::{canonicalize, room_obstacles, PitRoomContext, PitState, DEFAULT_SEED}; let context = |x: i32, y: i32| { @@ -189,6 +214,7 @@ fn pit_streams() -> Vec { f }) .collect(); + let scripted_jumps = pit_frames(scripted_jumps, 7); let mut streams = vec![ Stream { name: "scripted + jumps".into(), @@ -200,13 +226,13 @@ fn pit_streams() -> Vec { name: "random".into(), start: start(8.0, 3.0), context: context(0, 0), - inputs: random(0x1234_5678, 3000, 0), + inputs: pit_frames(random(0x1234_5678, 3000, 0), 1), }, Stream { name: "corner".into(), start: start(18.5, -18.5), context: context(0, 0), - inputs: random(0x0bad_cafe, 600, 0), + inputs: pit_frames(random(0x0bad_cafe, 600, 0), 2), }, ]; let mut patterns = std::collections::BTreeSet::new(); @@ -219,7 +245,7 @@ fn pit_streams() -> Vec { name: format!("pit.{x}.2 pattern {pattern}"), start: start(4.5, 4.5), context: context(x, 2), - inputs: random(0x51ed ^ x as u32, 1200, 0), + inputs: pit_frames(random(0x51ed ^ x as u32, 1200, 0), x as u32), }); } streams diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts b/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts index d1f859de..0d224eb4 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts @@ -17,7 +17,13 @@ export const PIT_RULES: PitRules = { states: ['idle', 'chase', 'windup', 'recover', 'return', 'dead'], attackRange: 2.200000047683716, attackHalfAngle: 1.0471975803375244, - swingFrames: 6 + swingFrames: 6, + range: 30, + fireCooldown: 12, + eyeHeight: 1.5, + hipHeight: 0.6000000238418579, + maxRewindMs: 600, + mobHeights: [0.8999999761581421, 0.8999999761581421, 1.5, 2, 2.799999952316284] }; /** `pit_world`, as the client decodes it. */ diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/events.ts b/examples/game/sveltekit/src/lib/generated/mobpit/events.ts index d0a40fdb..d42a4f85 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/events.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/events.ts @@ -59,6 +59,12 @@ export interface PitRules { readonly attackRange: number; readonly attackHalfAngle: number; readonly swingFrames: number; + readonly range: number; + readonly fireCooldown: number; + readonly eyeHeight: number; + readonly hipHeight: number; + readonly maxRewindMs: number; + readonly mobHeights: readonly number[]; } /** Record `PitState` (`#[derive(GameRecord)]`). */ @@ -257,6 +263,31 @@ const BOSS_DOWN_LAYOUT: PayloadLayout = [ ['hoodTier', { type: 'u32' }], ]; +/** Event `shot` (id 9). */ +export interface ShotEvent { + readonly shot: number; + readonly hit: number | undefined; + readonly compensated: boolean; + readonly ageMs: number; + readonly requestedAgeMs: number; + readonly clamped: boolean; + readonly x: number; + readonly y: number; + readonly z: number; +} + +const SHOT_LAYOUT: PayloadLayout = [ + ['shot', { type: 'u16' }], + ['hit', { type: 'optional', item: { type: 'u32' } }], + ['compensated', { type: 'bool' }], + ['ageMs', { type: 'f32' }], + ['requestedAgeMs', { type: 'f32' }], + ['clamped', { type: 'bool' }], + ['x', { type: 'f32' }], + ['y', { type: 'f32' }], + ['z', { type: 'f32' }], +]; + /** Message `loaded` (id 1). */ export interface LoadedMessage { readonly principal: string; @@ -291,6 +322,7 @@ export const EVENT = { died: 6, world: 7, bossDown: 8, + shot: 9, } as const; /** Event payload types by name. */ @@ -302,6 +334,7 @@ export interface Events { died: DiedEvent; world: WorldEvent; bossDown: BossDownEvent; + shot: ShotEvent; } /** Codecs of every event. */ @@ -348,6 +381,12 @@ export const EVENTS: GameEventCodecs = { encode: (value) => encodePayload(BOSS_DOWN_LAYOUT, value), decode: (payload) => decodePayload(BOSS_DOWN_LAYOUT, payload) as unknown as BossDownEvent }, + shot: { + id: 9, + name: 'shot', + encode: (value) => encodePayload(SHOT_LAYOUT, value), + decode: (payload) => decodePayload(SHOT_LAYOUT, payload) as unknown as ShotEvent + }, }; /** A decoded event, discriminated by `name`. */ diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/input.ts b/examples/game/sveltekit/src/lib/generated/mobpit/input.ts index 9e785027..1940d55a 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/input.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/input.ts @@ -8,6 +8,10 @@ export interface PitInput { readonly mx: number; readonly my: number; readonly buttons: number; + readonly yaw: number; + readonly pitch: number; + readonly shot: number; + readonly delayMs: number; } export type Input = PitInput; @@ -18,6 +22,9 @@ export const BITS = { dash: 1, attack: 2, jump: 4, + fire: 8, + noComp: 16, + aim: 32, }, } as const; @@ -29,12 +36,20 @@ export const INPUT_LAYOUT: PayloadLayout = [ ['mx', { type: 'i8' }], ['my', { type: 'i8' }], ['buttons', { type: 'u8' }], + ['yaw', { type: 'quant', min: -3.200000047683716, max: 3.199902296066284, bits: 16 }], + ['pitch', { type: 'quant', min: -1.600000023841858, max: 1.599951148033142, bits: 16 }], + ['shot', { type: 'u16' }], + ['delayMs', { type: 'u16' }], ]; const INPUT_RULES: readonly InputRule[] = [ { range: [-127, 127] }, { range: [-127, 127] }, - { buttons: 7 }, + { buttons: 63 }, + undefined, + undefined, + undefined, + undefined, ]; /** Payload bytes of one input frame. */ diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/schema.ts b/examples/game/sveltekit/src/lib/generated/mobpit/schema.ts index 62157019..0f2e9e2f 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/schema.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/schema.ts @@ -43,6 +43,8 @@ export interface PlayerFields { readonly level: number; /** `bool` */ readonly dead: boolean; + /** `u8` */ + readonly shot: number; } /** True for a `player` entity. */ @@ -68,6 +70,7 @@ export function playerFields(entity: { readonly fields: EntityFields }): PlayerF swing: fieldNumber(f, 'swing'), level: fieldNumber(f, 'level'), dead: fieldBool(f, 'dead'), + shot: fieldNumber(f, 'shot'), }; } @@ -184,6 +187,7 @@ export const FIELD_TYPES = { swing: { type: 'u8' }, level: { type: 'u16' }, dead: { type: 'bool' }, + shot: { type: 'u8' }, }, mob: { x: { type: 'quant', min: -20, max: 20, bits: 14 }, @@ -226,5 +230,6 @@ export const CORRECTIONS: CorrectionPolicies = { swing: { mode: 'replace' }, level: { mode: 'replace' }, dead: { mode: 'replace' }, + shot: { mode: 'replace' }, }, }; diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/surface.json b/examples/game/sveltekit/src/lib/generated/mobpit/surface.json index 4bb75a3d..19f49d99 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/surface.json +++ b/examples/game/sveltekit/src/lib/generated/mobpit/surface.json @@ -26,6 +26,18 @@ "value": { "attack_half_angle": 1.0471975803375244, "attack_range": 2.200000047683716, + "eye_height": 1.5, + "fire_cooldown": 12, + "hip_height": 0.6000000238418579, + "max_rewind_ms": 600, + "mob_heights": [ + 0.8999999761581421, + 0.8999999761581421, + 1.5, + 2.0, + 2.799999952316284 + ], + "range": 30.0, "states": [ "idle", "chase", @@ -408,6 +420,72 @@ ], "kind": "fields" } + }, + { + "id": 9, + "name": "shot", + "payload": { + "fields": [ + { + "name": "shot", + "type": { + "type": "u16" + } + }, + { + "name": "hit", + "type": { + "item": { + "type": "u32" + }, + "type": "optional" + } + }, + { + "name": "compensated", + "type": { + "type": "bool" + } + }, + { + "name": "age_ms", + "type": { + "type": "f32" + } + }, + { + "name": "requested_age_ms", + "type": { + "type": "f32" + } + }, + { + "name": "clamped", + "type": { + "type": "bool" + } + }, + { + "name": "x", + "type": { + "type": "f32" + } + }, + { + "name": "y", + "type": { + "type": "f32" + } + }, + { + "name": "z", + "type": { + "type": "f32" + } + } + ], + "kind": "fields" + } } ], "input": { @@ -436,12 +514,45 @@ "buttons": [ "dash", "attack", - "jump" + "jump", + "fire", + "no_comp", + "aim" ], "name": "buttons", "type": { "type": "u8" } + }, + { + "name": "yaw", + "type": { + "bits": 16, + "max": 3.1999023, + "min": -3.2, + "type": "quant" + } + }, + { + "name": "pitch", + "type": { + "bits": 16, + "max": 1.5999511, + "min": -1.6, + "type": "quant" + } + }, + { + "name": "shot", + "type": { + "type": "u16" + } + }, + { + "name": "delay_ms", + "type": { + "type": "u16" + } } ], "name": "PitInput" @@ -610,6 +721,16 @@ "type": { "type": "bool" } + }, + { + "correction": { + "mode": "replace" + }, + "name": "shot", + "priority": 1.0, + "type": { + "type": "u8" + } } ], "id": 1, @@ -1059,6 +1180,45 @@ "type": { "type": "u8" } + }, + { + "name": "range", + "type": { + "type": "f32" + } + }, + { + "name": "fire_cooldown", + "type": { + "type": "u8" + } + }, + { + "name": "eye_height", + "type": { + "type": "f32" + } + }, + { + "name": "hip_height", + "type": { + "type": "f32" + } + }, + { + "name": "max_rewind_ms", + "type": { + "type": "u32" + } + }, + { + "name": "mob_heights", + "type": { + "item": { + "type": "f32" + }, + "type": "list" + } } ], "name": "PitRules" diff --git a/examples/game/sveltekit/src/lib/pit/loop.ts b/examples/game/sveltekit/src/lib/pit/loop.ts index dec04fa6..a77c9068 100644 --- a/examples/game/sveltekit/src/lib/pit/loop.ts +++ b/examples/game/sveltekit/src/lib/pit/loop.ts @@ -10,11 +10,12 @@ * continues into the next room (the room hands the player over at the same * world point). */ -import { InputPacer, type EntityFields, type GameClient, type RenderedEntity } from '@hops-ops/distributed/game'; +import { InputPacer, onGameEvent, type EntityFields, type GameClient, type RenderedEntity } from '@hops-ops/distributed/game'; import { BUTTON, createMobpitPures, + EVENTS, PIT_RULES, PIT_WORLD, isMob, @@ -91,8 +92,26 @@ export type PitLoopOptions = Readonly<{ ghost?: () => boolean; /** The tiers above the room (borough weather darkens the view). */ world?: () => WorldTiers | undefined; + /** First person: the view turns with your aim (pointer lock), arrows leave from the eye. */ + firstPerson?: () => boolean; + /** Resolve arrows with lag compensation (default on). */ + compensated?: () => boolean; }>; +/** Entry distance of a ray (unit `dir`) into a vertical capsule on the floor. */ +function rayCapsule(origin: number[], dir: number[], x: number, y: number, radius: number, height: number): number | undefined { + const flat = Math.hypot(dir[0]!, dir[1]!); + if (flat < 1e-6) return undefined; + const [ux, uy] = [dir[0]! / flat, dir[1]! / flat]; + const [ox, oy] = [x - origin[0]!, y - origin[1]!]; + const along = ox * ux + oy * uy; + const off2 = ox * ox + oy * oy - along * along; + if (along <= 0 || off2 > radius * radius) return undefined; + const entry = (along - Math.sqrt(radius * radius - off2)) / flat; + const z = origin[2]! + dir[2]! * entry; + return z >= 0 && z <= height ? entry : undefined; +} + /** Darkness of the view (0..1): the borough's weather, thicker near a boss room. */ const WEATHER_DARK = [0.55, 0.4, 0.22, 0.05]; @@ -110,7 +129,122 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) window.addEventListener('keyup', up); const pacer = new InputPacer({ tickRate: () => client.authority?.connection.welcome?.tickRate }); + // ---- ranged: aim with the mouse (first person: pointer lock turns + // you), R or a click looses an arrow; markers yellow (as you saw it), + // then green / red with the room's verdict ---- + let yaw = Math.PI / 2; + let fireQueued = false; + let lastFire = 0; + let nextShot = 0; + type Shot = { zone: string; point: [number, number]; predicted: number | undefined; verdict?: { hit?: number; x: number; y: number }; at: number }; + const shots = new Map(); + const stats = { shots: 0, confirmed: 0, agreed: 0, resolved: 0 }; + const first = () => options.firstPerson?.() ?? false; + const offFail = client.on('handoffFailed', (f) => { + canvas.dataset.handoffFailed = `${canvas.dataset.handoffFailed ?? ''} ${f.from}→${f.to} ${f.stage} ${f.reason};`; + }); + const offClosed = client.on('zoneClosed', (zone, info) => { + canvas.dataset.zoneClosed = `${canvas.dataset.zoneClosed ?? ''} ${zone} ${info.reason} ${info.code ?? ''};`; + }); + const offShots = onGameEvent(client, EVENTS, 'shot', (v) => { + const shot = shots.get(v.shot); + if (shot === undefined || shot.verdict !== undefined) return; + shot.verdict = { hit: v.hit, x: v.x, y: v.y }; + shot.at = performance.now(); + stats.resolved += 1; + if (v.hit !== undefined) stats.confirmed += 1; + if (v.hit === shot.predicted) stats.agreed += 1; + }); + const onMove = (e: MouseEvent) => { + if (first()) { + if (document.pointerLockElement === canvas) yaw -= e.movementX * 0.004; + return; + } + // Top-down: aim at the cursor (you are at the centre). + const r = canvas.getBoundingClientRect(); + const cx = ((e.clientX - r.left) * canvas.width) / r.width - canvas.width / 2; + const cy = ((e.clientY - r.top) * canvas.height) / r.height - canvas.height / 2; + if (Math.hypot(cx, cy) > 4) yaw = Math.atan2(-cy, cx); + }; + const onDown = (e: MouseEvent) => { + if (e.button !== 0) return; + if (first() && document.pointerLockElement !== canvas) { + Promise.resolve(canvas.requestPointerLock?.()).catch(() => undefined); + return; + } + fireQueued = true; + }; + const onKey = (e: KeyboardEvent) => { + if (e.code === 'KeyR' && !e.repeat) fireQueued = true; + }; + canvas.addEventListener('mousemove', onMove); + document.addEventListener('mousemove', onMove); + canvas.addEventListener('mousedown', onDown); + window.addEventListener('keydown', onKey); + + /** The arrow fields of this frame, when one is loosed. */ + const loose = (): Partial => { + const zone = client.authority; + const hello = options.hello(); + const now = performance.now(); + if (!fireQueued || zone === undefined || hello === undefined || now - lastFire < (PIT_RULES.fireCooldown * 1000) / 30) return {}; + fireQueued = false; + const me = client.render().find((e) => e.id === hello.entity && isPlayer(e) && e.zoneId === zone.zoneId); + if (me === undefined || !zone.connection.canSendInput()) return {}; + const p = playerFields(me); + // Top-down: level from the hip. First person: from the eye, pitched + // at the middle of the mob straight ahead (the view has no pitch). + const fp = first(); + const origin = [p.x, p.y, p.z + (fp ? PIT_RULES.eyeHeight : PIT_RULES.hipHeight)]; + let pitch = 0; + if (fp) { + const ahead = client + .render() + .filter((e) => isMob(e) && !e.stale && e.zoneId === zone.zoneId) + .map((e) => mobFields(e)) + .map((m) => ({ m, d: rayCapsule([p.x, p.y, 0.1], [Math.cos(yaw), Math.sin(yaw), 0], m.x, m.y, TIER_RADIUS[m.tier]?.radius ?? 0.5, 10) })) + .filter((a) => a.d !== undefined) + .sort((a, b) => a.d! - b.d!)[0]; + if (ahead !== undefined) pitch = Math.atan2((PIT_RULES.mobHeights[ahead.m.tier] ?? 1) / 2 - origin[2]!, ahead.d! + 0.3); + } + const dir = [Math.cos(yaw) * Math.cos(pitch), Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch)]; + const context = client.inputContext(zone)!; + const delayMs = Math.max(0, zone.connection.tickTime(context.clientTick) - (zone.connection.serverNow() - context.interpolationDelayMs)); + let predicted: number | undefined; + let best = PIT_RULES.range; + for (const e of client.render()) { + if (!isMob(e) || e.stale || e.zoneId !== zone.zoneId) continue; + const m = mobFields(e); + const d = rayCapsule(origin, dir, m.x, m.y, TIER_RADIUS[m.tier]?.radius ?? 0.5, PIT_RULES.mobHeights[m.tier] ?? 1); + if (d !== undefined && d < best) { + best = d; + predicted = e.id; + } + } + nextShot = (nextShot % 0xffff) + 1; + shots.set(nextShot, { zone: zone.zoneId, point: [origin[0]! + dir[0]! * best, origin[1]! + dir[1]! * best], predicted, at: now }); + if (shots.size > 64) shots.delete(shots.keys().next().value!); + stats.shots += 1; + lastFire = now; + const compensated = options.compensated?.() ?? true; + return { buttons: BUTTON.fire | (compensated ? 0 : BUTTON.noComp), shot: nextShot, delayMs: Math.min(0xffff, Math.round(delayMs)), pitch }; + }; + const steer = (): PitInput => { + const base = steerMove(); + const arrow = loose(); + return { + ...base, + // First person faces the aim (and the stick turns with it). + buttons: base.buttons | (arrow.buttons ?? 0) | (first() ? BUTTON.aim : 0), + yaw, + pitch: arrow.pitch ?? 0, + shot: arrow.shot ?? 0, + delayMs: arrow.delayMs ?? 0 + }; + }; + + const steerMove = (): Pick => { const hello = options.hello(); const room = client.authority?.zoneId; const rendered = client.render().filter((e) => room === undefined || e.zoneId === room); @@ -123,6 +257,8 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) .map((m) => ({ m, d: Math.hypot(m.x - p.x, m.y - p.y) })) .sort((a, b) => a.d - b.d)[0]; if (target === undefined || p.dead) return { mx: 0, my: 0, buttons: 0 }; + // Face the prey (an arrow loosed meanwhile flies at it). + yaw = Math.atan2(target.m.y - p.y, target.m.x - p.x); const near = target.d < 1.9; const k = (near ? 40 : 127) / Math.max(target.d, 1e-3); return { @@ -132,10 +268,16 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) }; } const axis = (neg: string, pos: string) => (keys.has(pos) ? 127 : 0) - (keys.has(neg) ? 127 : 0); + let [mx, my] = [axis('KeyA', 'KeyD'), axis('KeyS', 'KeyW')]; + if (first()) { + // W walks where you look, D to its right. + const [fx, fy] = [Math.cos(yaw), Math.sin(yaw)]; + [mx, my] = [Math.round(fy * mx + fx * my), Math.round(-fx * mx + fy * my)]; + } return { - mx: axis('KeyA', 'KeyD'), + mx: Math.max(-127, Math.min(127, mx)), // Screen y grows downwards; world y grows upwards. - my: axis('KeyS', 'KeyW'), + my: Math.max(-127, Math.min(127, my)), buttons: (keys.has('Space') ? BUTTON.attack : 0) | (keys.has('KeyE') ? BUTTON.dash : 0) | (keys.has('KeyQ') ? BUTTON.jump : 0) }; }; @@ -146,6 +288,7 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) /** Pillars are the same in every room: learned from the replicated ones. */ let pillars: { x: number; y: number; r: number }[] = []; let lastMe: { x: number; y: number } | undefined; + let lastZone: string | undefined; let lastFrame = 0; let frame = requestAnimationFrame(function draw(now) { @@ -167,9 +310,13 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) // apart; the headless test resets it to 0 before a doorway. if (lastMe !== undefined && now - lastFrame <= 50) { const jump = Math.hypot(me.x - lastMe.x, me.y - lastMe.y); - if (jump > Number(canvas.dataset.jump ?? 0)) canvas.dataset.jump = jump.toFixed(3); + if (jump > Number(canvas.dataset.jump ?? 0)) { + canvas.dataset.jump = jump.toFixed(3); + canvas.dataset.jumpDebug = JSON.stringify({ from: lastMe, to: me, zone: meEntity?.zoneId, lastZone, room, predicted: meEntity?.predicted, fields: meEntity?.fields }); + } } lastMe = me; + lastZone = meEntity?.zoneId; } lastFrame = now; const { width, height } = canvas; @@ -192,8 +339,15 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) ctx.lineTo(...at(x1, y1)); ctx.stroke(); }; + ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = '#0b090d'; ctx.fillRect(0, 0, width, height); + // First person: the world turns so that your aim points up. + if (first()) { + ctx.translate(width / 2, height / 2); + ctx.rotate(yaw - Math.PI / 2); + ctx.translate(-width / 2, -height / 2); + } const seed = hello?.seed ?? PIT_WORLD.defaultSeed; const [cx, cy] = [Math.round(centre.x / ROOM), Math.round(centre.y / ROOM)]; for (let dy = -1; dy <= 1; dy += 1) { @@ -314,6 +468,43 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) } } canvas.dataset.nextDoorMobs = String(nextDoor); + // Arrows: the aim line, markers (yellow: as you saw it; green / red: + // the room's verdict), the counters for the headless test. + if (me !== undefined) { + const [ax, ay] = at(me.x, me.y); + const [bx, by] = at(me.x + Math.cos(yaw) * 6, me.y + Math.sin(yaw) * 6); + ctx.strokeStyle = 'rgba(255,209,102,0.6)'; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.lineTo(bx, by); + ctx.stroke(); + ctx.setLineDash([]); + } + const tnow = performance.now(); + for (const [id, shot] of shots) { + const r = parseRoom(shot.zone); + if (r === undefined) continue; + const p = shot.verdict ?? { x: shot.point[0], y: shot.point[1], hit: undefined }; + const color = shot.verdict === undefined ? '#ffd166' : shot.verdict.hit !== undefined ? '#3ddc84' : '#ff5252'; + circle(r[0] * ROOM + p.x, r[1] * ROOM + p.y, 0.3, color); + if (shot.verdict !== undefined && tnow - shot.at > 1200) shots.delete(id); + } + canvas.dataset.shots = String(stats.shots); + canvas.dataset.confirmed = String(stats.confirmed); + canvas.dataset.agreed = String(stats.agreed); + canvas.dataset.resolved = String(stats.resolved); + ctx.setTransform(1, 0, 0, 1, 0, 0); + if (first()) { + // Crosshair: ahead of you, straight up the screen. + ctx.strokeStyle = '#fff'; + ctx.beginPath(); + ctx.moveTo(width / 2 - 8, height / 2 - 6 * scale); + ctx.lineTo(width / 2 + 8, height / 2 - 6 * scale); + ctx.moveTo(width / 2, height / 2 - 6 * scale - 8); + ctx.lineTo(width / 2, height / 2 - 6 * scale + 8); + ctx.stroke(); + } // Weather and darkness: the borough's sky, thicker toward bosses. if (here !== undefined && pures.loaded()) { const world = options.world?.(); @@ -330,5 +521,12 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) cancelAnimationFrame(frame); window.removeEventListener('keydown', down); window.removeEventListener('keyup', up); + window.removeEventListener('keydown', onKey); + canvas.removeEventListener('mousemove', onMove); + document.removeEventListener('mousemove', onMove); + canvas.removeEventListener('mousedown', onDown); + offShots(); + offFail(); + offClosed(); }; } diff --git a/examples/game/sveltekit/src/routes/pit/+page.svelte b/examples/game/sveltekit/src/routes/pit/+page.svelte index 411995dd..0a99d2d0 100644 --- a/examples/game/sveltekit/src/routes/pit/+page.svelte +++ b/examples/game/sveltekit/src/routes/pit/+page.svelte @@ -98,10 +98,20 @@ let autoHunt = $state(false); let ghost = $state(true); let predict = $state(true); + let firstPerson = $state(false); + let compensated = $state(true); $effect(() => { const client = game.client; if (client === undefined || canvas === undefined) return; - return startPitLoop(canvas, { client, hello: () => hello.current, autoHunt: () => autoHunt, ghost: () => ghost, world: () => world }); + return startPitLoop(canvas, { + client, + hello: () => hello.current, + autoHunt: () => autoHunt, + ghost: () => ghost, + world: () => world, + firstPerson: () => firstPerson, + compensated: () => compensated + }); }); $effect(() => game.client?.setPredictionEnabled(predict)); @@ -174,6 +184,12 @@ WASD move · Space attack · E dash · Q jump · walk through a doorway (the gaps in the walls) to the next room
    +
    +

    Bow

    + + + R / click shoots where you aim (the mouse). Yellow: where you saw it land; green / red: the room's verdict. +

    World

    {#if world} diff --git a/examples/game/sveltekit/tests/pit.test.mjs b/examples/game/sveltekit/tests/pit.test.mjs index 35da92a2..dd358726 100644 --- a/examples/game/sveltekit/tests/pit.test.mjs +++ b/examples/game/sveltekit/tests/pit.test.mjs @@ -103,6 +103,7 @@ async function walkThrough(page, side) { const room = () => page.evaluate(() => document.querySelector('canvas')?.dataset.room); const world = () => page.evaluate(() => document.querySelector('canvas')?.dataset.world?.split(',').map(Number)); const from = await room(); + console.error(`before ${side}: ${await page.evaluate(() => JSON.stringify({ me: document.querySelector('canvas').dataset.me, ghostGap: document.querySelector('canvas').dataset.ghostGap }))}`); await page.evaluate(() => (document.querySelector('canvas').dataset.jump = '0')); const before = await world(); const arrived = await walkInto(page, goal, room, from); @@ -113,6 +114,7 @@ async function walkThrough(page, side) { const after = await world(); const [dx, dy] = { east: [1, 0], west: [-1, 0], north: [0, 1], south: [0, -1] }[side]; console.error(`doorway ${from} → ${arrived}: largest frame move ${jump} m, world ${before} → ${after}`); + if (jump > MAX_JUMP) console.error(`JUMP DEBUG ${await page.evaluate(() => JSON.stringify({ jump: document.querySelector('canvas').dataset.jumpDebug, failed: document.querySelector('canvas').dataset.handoffFailed, closed: document.querySelector('canvas').dataset.zoneClosed }))}`); assert.ok(jump <= MAX_JUMP, `${from} → ${arrived}: the view jumped ${jump} m in one frame`); // Moved on in the doorway's direction, a few metres, not a room. const along = (after[0] - before[0]) * dx + (after[1] - before[1]) * dy; @@ -448,6 +450,22 @@ test('sign in, join, move, kill, walk through rooms on celld, live read models f console.error(`world event (read model, @live): ${await page.textContent('[data-testid=events] li[data-borough="0.0"]')}`); await page.waitForSelector('[data-testid=world-event]', { timeout: 10_000 }); + // Bow: auto-hunt turns ana to the nearest mob; R looses arrows; the + // room rewinds the mobs to what she saw (lag compensation) and its + // verdicts come back (green markers). First person turns the view. + await page.check('[data-testid=autohunt]'); + await page.check('[data-testid=first-person]'); + for (let i = 0; i < 30; i += 1) { + if (Number(await page.evaluate(() => document.querySelector('canvas')?.dataset.confirmed ?? 0)) > 0) break; + await page.keyboard.press('KeyR'); + await page.waitForTimeout(450); + } + await page.waitForFunction(() => Number(document.querySelector('canvas')?.dataset.confirmed ?? 0) > 0, null, { timeout: 10_000 }); + const bow = await page.evaluate(() => ({ ...document.querySelector('canvas').dataset })); + console.error(`bow: ${bow.shots} arrows, ${bow.resolved} verdicts, ${bow.confirmed} confirmed hits, ${bow.agreed} as seen`); + await page.uncheck('[data-testid=first-person]'); + await page.uncheck('[data-testid=autohunt]'); + // SSR after a reload: the settled numbers are in the server HTML. const html = await page.evaluate(() => fetch('/pit', { headers: { accept: 'text/html' } }).then((r) => r.text())); assert.match(html, new RegExp(`data-testid="kills"[^>]*>${kills}<`)); From d45f643a1e8a5a9ad736b73b2de0e305408046d5 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 21 Oct 2026 03:28:33 -0500 Subject: [PATCH 12/21] feat(distributed_game): live content rollout for celld zones A world's content rollout as a cell (`world::content_rollout`, `ContentRolloutCell` on celld): an admin session starts it; the cell freezes, applies and settles on its own schedule, each phase an outward event. Zone objects with `ZONE_CONTENT_ROLLOUT` follow it (read on wake and every second while ticking; a Hello on another version waits for the read): freeze with the change notes, `set_content_version`, resume; a hard rollout reloads stale clients. The snapshot's content version is restored on wake. Protocol: client message `Content` (0x05) tells the zone a client patched its content live; recorded only for the zone's current version. The SDK sends it from `setContentVersion`. `Ctx::is_stale` and `player_content_version` let a zone gate doorways for old clients. [[tasks/gr-p44-mobpit-world]] --- distributed_game/Cargo.toml | 5 + distributed_game/PROTOCOL.md | 8 + distributed_game/src/celld_zone/cells.rs | 46 + distributed_game/src/celld_zone/mod.rs | 20 +- distributed_game/src/celld_zone/object.rs | 124 ++- distributed_game/src/celld_zone/plan.rs | 10 + distributed_game/src/host.rs | 45 + distributed_game/src/net/mod.rs | 14 + distributed_game/src/protocol/messages.rs | 15 + distributed_game/src/world/content_rollout.rs | 398 +++++++ distributed_game/src/world/mod.rs | 5 + .../tests/content_rollout/main.rs | 123 +++ distributed_game/tests/fixtures/messages.json | 9 + distributed_game/tests/golden/main.rs | 9 + examples/game/client/freeze/index.html | 13 - examples/game/client/src/freeze/client.ts | 277 ----- examples/game/client/src/freeze/freeze.css | 161 --- examples/game/client/src/freeze/main.ts | 407 -------- .../client/src/generated/freeze/client.ts | 68 -- .../client/src/generated/freeze/constants.ts | 93 -- .../client/src/generated/freeze/events.ts | 245 ----- .../game/client/src/generated/freeze/index.ts | 9 - .../game/client/src/generated/freeze/input.ts | 51 - .../client/src/generated/freeze/predict.ts | 51 - .../client/src/generated/freeze/schema.ts | 163 --- .../client/src/generated/freeze/surface.json | 980 ------------------ .../client/tests/freeze-headless.test.mjs | 183 ---- examples/game/server/src/freeze/content.rs | 443 -------- examples/game/server/src/freeze/mod.rs | 673 ------------ examples/game/server/src/freeze/world.rs | 768 -------------- examples/game/server/tests/freeze_smoke.rs | 292 ------ js/src/game/connection.ts | 13 +- js/src/game/messages.ts | 11 +- js/tests/game-codec.test.mjs | 6 +- 34 files changed, 851 insertions(+), 4887 deletions(-) create mode 100644 distributed_game/src/world/content_rollout.rs create mode 100644 distributed_game/tests/content_rollout/main.rs delete mode 100644 examples/game/client/freeze/index.html delete mode 100644 examples/game/client/src/freeze/client.ts delete mode 100644 examples/game/client/src/freeze/freeze.css delete mode 100644 examples/game/client/src/freeze/main.ts delete mode 100644 examples/game/client/src/generated/freeze/client.ts delete mode 100644 examples/game/client/src/generated/freeze/constants.ts delete mode 100644 examples/game/client/src/generated/freeze/events.ts delete mode 100644 examples/game/client/src/generated/freeze/index.ts delete mode 100644 examples/game/client/src/generated/freeze/input.ts delete mode 100644 examples/game/client/src/generated/freeze/predict.ts delete mode 100644 examples/game/client/src/generated/freeze/schema.ts delete mode 100644 examples/game/client/src/generated/freeze/surface.json delete mode 100644 examples/game/client/tests/freeze-headless.test.mjs delete mode 100644 examples/game/server/src/freeze/content.rs delete mode 100644 examples/game/server/src/freeze/mod.rs delete mode 100644 examples/game/server/src/freeze/world.rs delete mode 100644 examples/game/server/tests/freeze_smoke.rs diff --git a/distributed_game/Cargo.toml b/distributed_game/Cargo.toml index 198ed9ea..89879622 100644 --- a/distributed_game/Cargo.toml +++ b/distributed_game/Cargo.toml @@ -97,6 +97,11 @@ name = "zone_lease" path = "tests/zone_lease/main.rs" required-features = ["cell"] +[[test]] +name = "content_rollout" +path = "tests/content_rollout/main.rs" +required-features = ["cell"] + [[test]] name = "zone_state" path = "tests/zone_state/main.rs" diff --git a/distributed_game/PROTOCOL.md b/distributed_game/PROTOCOL.md index 02e4ea2c..56995f22 100644 --- a/distributed_game/PROTOCOL.md +++ b/distributed_game/PROTOCOL.md @@ -194,6 +194,7 @@ replication priorities are not transmitted. | `0x02` | C→S | Input | | `0x03` | C→S | Ping | | `0x04` | C→S | Leave | +| `0x05` | C→S | Content | | `0x80` | S→C | Welcome | | `0x81` | S→C | Delta | | `0x82` | S→C | Event | @@ -216,6 +217,7 @@ Hello := 0x01, varuint protocol_version, string content_version, Input := 0x02, varuint seq, varuint client_tick, bytes payload Ping := 0x03, f64 client_time Leave := 0x04 +Content := 0x05, string content_version ``` - **Hello** MUST be the first message, sent within the server's hello @@ -239,6 +241,12 @@ Leave := 0x04 - **Ping**: the server answers with Pong immediately. - **Leave**: graceful exit; the player is removed at once (no reconnect grace). +- **Content**: the client patched its content live (typically after a + `Resume` carrying a new version) and now runs `content_version`. The + server records it for the connection's player only when it equals the + zone's current content version (a client can catch up, never get + ahead) and ignores it otherwise; observers' are ignored. Without it a + live-patched client counts as stale until its next Hello. Worked example — Hello (`version 1, content "c1", caps 5, ticket "t.k.n"`): diff --git a/distributed_game/src/celld_zone/cells.rs b/distributed_game/src/celld_zone/cells.rs index a72b21e0..34ef151d 100644 --- a/distributed_game/src/celld_zone/cells.rs +++ b/distributed_game/src/celld_zone/cells.rs @@ -12,6 +12,7 @@ use serde_json::{json, Value}; use worker::{Env, Method, Request, Response, Result, State}; use crate::cell::fact_session_from_headers; +use crate::world::content_rollout::{mount_content_rollout, ContentRollout}; use crate::world::zone_activity::{mount_zone_activity, ZoneActivity}; /// Builds the handler session from trusted request headers (after the @@ -232,3 +233,48 @@ impl ZoneDevSettings { } } } + +/// The content-rollout cell of a game worker (`ContentRollout:{world}`, +/// kind [`CONTENT_ROLLOUT_KIND`](crate::world::content_rollout::CONTENT_ROLLOUT_KIND), +/// binding `CONTENT_ROLLOUT`): the world's live content version and rollout +/// phase, read by every zone object ([`ObjectOptions::content_rollout`](super::ObjectOptions::content_rollout)). +/// `sessions` decides who may start a rollout: it must give the admin's +/// session the [`CONTENT_ADMIN_ROLE`](crate::world::content_rollout::CONTENT_ADMIN_ROLE) +/// (the cell's own schedules need no session). `GET` returns +/// `{"state": ContentState | null}`. +pub struct ContentRolloutCell { + cell: AggregateCell, + env: Env, + sessions: Sessions, +} + +impl ContentRolloutCell { + /// Open the cell of `state`, publishing its events to `outbox`. + pub fn new(state: State, env: Env, outbox: CelldOutbox, sessions: Sessions) -> Self { + let cell = mount_content_rollout( + AggregateCell::::from_state_with_snapshots(state, 20) + .expect("content rollout cell identity"), + ) + .with_celld_outbox(outbox); + Self { + cell, + env, + sessions, + } + } + + /// Durable Object `fetch`. + pub async fn fetch(&self, req: Request) -> Result { + serve_cell(&self.env, &self.cell, req, read_rollout, self.sessions).await + } + + /// Durable Object `alarm` (the rollout's own schedule). + pub async fn alarm(&self) -> Result { + cell_alarm(&self.env, &self.cell).await + } +} + +async fn read_rollout(cell: &AggregateCell) -> std::result::Result { + let rollout = cell.load().await.map_err(|error| error.to_string())?; + Ok(json!({ "state": rollout.map(|r| r.state()) })) +} diff --git a/distributed_game/src/celld_zone/mod.rs b/distributed_game/src/celld_zone/mod.rs index c0e1ab80..e0318950 100644 --- a/distributed_game/src/celld_zone/mod.rs +++ b/distributed_game/src/celld_zone/mod.rs @@ -68,6 +68,20 @@ //! Only a player wakes a zone. A watcher joining a dormant zone gets its //! current state at once ([`ZoneHost::patch_now`](crate::ZoneHost::patch_now)). //! +//! **Live content updates.** With `ZONE_CONTENT_ROLLOUT=` every +//! zone object follows the world's +//! [`content_rollout`](crate::world::content_rollout) cell +//! ([`ContentRolloutCell`], binding `CONTENT_ROLLOUT`): it reads it on wake +//! and every second while it ticks and runs +//! [`zone_step`](crate::world::content_rollout::zone_step) — freeze with +//! the change notes, then switch to the new version and resume (a hard +//! rollout reloads stale clients at once). A zone that slept through a +//! rollout restores its snapshot's version and catches up on wake; a Hello +//! whose content version differs from the zone's waits for that read +//! before admission, so a doorway into a sleeping zone admits on the live +//! version. Stale clients are handled by the host's +//! [`StalePolicy`](crate::world::StalePolicy). +//! //! Dev only: with `ZONE_DEV_SETTINGS=1` every zone object applies the //! network simulation ([`NetSim`](crate::netsim::NetSim): latency, jitter, //! loss per socket and direction) stored in the worker's @@ -89,13 +103,13 @@ mod object; pub use plan::{ cell_binding, ObjectOptions, RoomState, StoredFact, TickSchedule, CLOSE_RESTARTED, - DEV_SETTINGS_VAR, NETSIM_SHARD, ZONE_DEV_KIND, ZONE_HEADER, + CONTENT_ROLLOUT_VAR, DEV_SETTINGS_VAR, NETSIM_SHARD, ZONE_DEV_KIND, ZONE_HEADER, }; #[cfg(target_arch = "wasm32")] pub use cells::{ - authenticate, cell_alarm, error_response, fact_sessions, json_status, serve_cell, Sessions, - ZoneActivityCell, ZoneDevSettings, + authenticate, cell_alarm, error_response, fact_sessions, json_status, serve_cell, + ContentRolloutCell, Sessions, ZoneActivityCell, ZoneDevSettings, }; #[cfg(target_arch = "wasm32")] pub use object::{ diff --git a/distributed_game/src/celld_zone/object.rs b/distributed_game/src/celld_zone/object.rs index 8ddf8c0f..b66f8bca 100644 --- a/distributed_game/src/celld_zone/object.rs +++ b/distributed_game/src/celld_zone/object.rs @@ -33,13 +33,15 @@ use crate::facts::{ use crate::handoff::{HandoffRequest, Transfer}; use crate::inbox::ZoneInbound; use crate::netsim::{DelayLine, NetSim, NetSimJson}; -use crate::protocol::{error, ClientMessage, Role, ServerMessage, PROTOCOL_VERSION}; +use crate::protocol::{error, ClientMessage, Notice, Role, ServerMessage, PROTOCOL_VERSION}; use crate::stamp::FactStamp; use crate::ticket::{NonceCache, TicketKey, VerifiedTicket}; +use crate::world::content_rollout::{zone_step, ContentState, ZoneStep, CONTENT_ROLLOUT_KIND}; use crate::world::zone_activity::{ zone_principal, RecordActivity, ACTIVITY_ACTIVE, ACTIVITY_DORMANT, ACTIVITY_HIBERNATED, ZONE_ACTIVITY_KIND, }; +use crate::world::FreezeState; use crate::zone::Viewer; use crate::{ClientId, Clock, HostConfig, Outbound, TicketClaims, Zone, ZoneHost}; @@ -430,6 +432,8 @@ struct Inner { /// Spawns a drain task waking at the given time (set by the object). drainer: Option>, last_dev_read_ms: f64, + /// Last read of the world's content rollout cell. + last_content_read_ms: f64, /// [`CelldZone::refresh`] calls of this activation, and the last one. refresh_round: u64, last_refresh_ms: f64, @@ -704,6 +708,7 @@ impl ZoneObject { drain_at: None, drainer: None, last_dev_read_ms: 0.0, + last_content_read_ms: 0.0, refresh_round: 0, last_refresh_ms: 0.0, }; @@ -828,6 +833,12 @@ impl ZoneObject { let _ = ws.send_with_bytes(pong); } } + (Some(client), ClientMessage::Content { content_version }) => { + let mut inner = self.inner.borrow_mut(); + if let Some(host) = inner.host.as_mut() { + host.content_patched(ClientId(client), &content_version); + } + } (Some(client), ClientMessage::Leave) => { { let mut inner = self.inner.borrow_mut(); @@ -1029,6 +1040,11 @@ impl ZoneObject { host_config.idle_tick_rate = None; host_config.reconnect_grace = options.reconnect_grace; host_config.boot_epoch = (now_ms() * 1000.0) as u64; + // The content version the zone slept on (a rollout it missed is + // caught up by `read_content`). + if let Some(snapshot) = &stored { + host_config.content_version = snapshot.version.clone(); + } let key = inner.key.clone().expect("ticket key with options"); let issuer_zone = zone_id.clone(); let generation = inner.activation_epoch; @@ -1101,6 +1117,11 @@ impl ZoneObject { // with the player's inputs waiting) does not wait for dev settings. let this = self.handle(); wasm_bindgen_futures::spawn_local(async move { this.read_dev_settings().await }); + // The snapshot restored the zone's last content version; catch up + // with a rollout it slept through (off the join path too: a Hello + // on another version waits for its own read, see `hello`). + let this = self.handle(); + wasm_bindgen_futures::spawn_local(async move { this.read_content().await }); self.refresh(); Ok(()) } @@ -1177,6 +1198,85 @@ impl ZoneObject { } } + /// Read the world's content rollout cell and follow it + /// ([`zone_step`]: freeze, apply + resume). + async fn read_content(&self) { + let world = { + let mut inner = self.inner.borrow_mut(); + inner.last_content_read_ms = now_ms(); + inner + .options + .as_ref() + .and_then(|o| o.content_rollout.clone()) + }; + let Some(world) = world else { + return; + }; + match self.env.read_cell(CONTENT_ROLLOUT_KIND, &world).await { + Ok(value) => { + let state = value + .get("state") + .cloned() + .and_then(|state| serde_json::from_value::(state).ok()); + if let Some(state) = state { + self.apply_content(&state); + } + } + Err(e) => worker::console_warn!("content rollout: {e}"), + } + } + + fn apply_content(&self, state: &ContentState) { + let mut inner = self.inner.borrow_mut(); + let Some(host) = inner.host.as_mut() else { + return; + }; + let frozen = host.freeze_state() != FreezeState::Running; + let current = host.config().content_version.clone(); + match zone_step(state, ¤t, frozen) { + ZoneStep::Stay => return, + ZoneStep::Freeze => { + host.request_freeze(Notice { + reason: "content_update".into(), + version: state.target.clone(), + notes: state.notes.clone(), + }); + } + ZoneStep::Apply(version) => { + host.set_content_version(version.clone()); + if frozen { + host.resume(Notice { + reason: "content_updated".into(), + version, + notes: state.notes.clone(), + }); + } + if state.hard { + host.reload_stale_players(); + } + } + } + inner.flush(); + } + + /// [`read_content`](Self::read_content) off the tick, at most every + /// [`DEV_SETTINGS_EVERY_MS`]. + fn refresh_content(&self) { + let due = { + let inner = self.inner.borrow(); + inner + .options + .as_ref() + .is_some_and(|o| o.content_rollout.is_some()) + && now_ms() - inner.last_content_read_ms >= DEV_SETTINGS_EVERY_MS + }; + if due { + self.inner.borrow_mut().last_content_read_ms = now_ms(); + let this = self.handle(); + wasm_bindgen_futures::spawn_local(async move { this.read_content().await }); + } + } + async fn hello(&self, ws: &WebSocket, hello: crate::protocol::Hello) -> Result<()> { if hello.protocol_version != PROTOCOL_VERSION { reject(ws, error::VERSION_MISMATCH, "protocol version"); @@ -1253,6 +1353,23 @@ impl ZoneObject { if claims.role == Role::Player { principal = Some(claims.principal.clone()); } + // A client on another content version than the zone: the zone + // may be behind the world (it slept through a rollout), so it + // learns the live version before deciding. + let behind = { + let inner = self.inner.borrow(); + inner + .options + .as_ref() + .is_some_and(|o| o.content_rollout.is_some()) + && inner + .host + .as_ref() + .is_some_and(|h| h.config().content_version != hello.content_version) + }; + if behind { + self.read_content().await; + } let mut inner = self.inner.borrow_mut(); let Some(host) = inner.host.as_mut() else { drop(inner); @@ -1408,6 +1525,7 @@ impl ZoneObject { }; self.spool().await; self.refresh_dev_settings(); + self.refresh_content(); self.refresh_if_due(); if persist { self.persist().await; @@ -1607,6 +1725,10 @@ impl ZoneObject { "droppedTicks": stats.dropped, "lateMs": { "p50": stats.late.percentile(0.5), "p99": stats.late.percentile(0.99) }, "netsim": NetSim::to_json(inner.net_sim), + "content": host.map(|h| json!({ + "version": h.config().content_version, + "freeze": format!("{:?}", h.freeze_state()).to_lowercase(), + })), "workMs": { "p50": stats.work.percentile(0.5), "p99": stats.work.percentile(0.99) }, }) } diff --git a/distributed_game/src/celld_zone/plan.rs b/distributed_game/src/celld_zone/plan.rs index f2847e1d..b1599535 100644 --- a/distributed_game/src/celld_zone/plan.rs +++ b/distributed_game/src/celld_zone/plan.rs @@ -39,6 +39,10 @@ pub const NODE_VAR: &str = "CELLD_ZONE_NODE"; /// Env var (dev only): `1` makes zone objects read the worker's dev /// settings (the network simulation, [`NETSIM_SHARD`]). pub const DEV_SETTINGS_VAR: &str = "ZONE_DEV_SETTINGS"; +/// Env var: the world whose content rollout the zones follow (the shard of +/// the worker's `content-rollout` cell, e.g. `world`; unset: no live +/// content updates). +pub const CONTENT_ROLLOUT_VAR: &str = "ZONE_CONTENT_ROLLOUT"; pub use crate::netsim::{NETSIM_SHARD, ZONE_DEV_KIND}; /// Request header naming the zone (set by [`super::forward_to_zone`]; the @@ -74,6 +78,9 @@ pub struct ObjectOptions { pub node: Option, /// Dev only: read the worker's dev settings (network simulation). pub dev_settings: bool, + /// The world (`content-rollout` shard) whose live content version and + /// rollout the zone follows. + pub content_rollout: Option, } fn parse( @@ -125,6 +132,9 @@ impl ObjectOptions { node: get(NODE_VAR).filter(|node| !node.is_empty()), dev_settings: get(DEV_SETTINGS_VAR) .is_some_and(|v| matches!(v.trim(), "1" | "true" | "yes")), + content_rollout: get(CONTENT_ROLLOUT_VAR) + .map(|world| world.trim().to_string()) + .filter(|world| !world.is_empty()), }) } } diff --git a/distributed_game/src/host.rs b/distributed_game/src/host.rs index 380bc0d8..0901f891 100644 --- a/distributed_game/src/host.rs +++ b/distributed_game/src/host.rs @@ -439,6 +439,31 @@ impl Ctx<'_, Z> { &self.core.config.content_version } + /// Content version `player`'s client runs (what it presented on its + /// last Hello; differs from [`content_version`](Self::content_version) + /// for a stale client kept under [`StalePolicy::SafeRoom`]). + pub fn player_content_version(&self, player: PlayerId) -> Option<&str> { + self.core + .players + .get(&player) + .map(|slot| slot.content_version.as_str()) + } + + /// True when `player`'s client runs older content than the zone (it + /// may only be handed off into a gate zone, unless the policy roams). + pub fn is_stale(&self, player: PlayerId) -> bool { + let expected = &self.core.config.content_version; + !expected.is_empty() + && self + .player_content_version(player) + .is_some_and(|version| version != expected) + } + + /// The host's [`StalePolicy`]. + pub fn stale_policy(&self) -> StalePolicy { + self.core.config.stale_policy + } + /// Remote config version ([`HostConfig::config_version`]). pub fn config_version(&self) -> &str { &self.core.config.config_version @@ -1411,6 +1436,26 @@ impl ZoneHost { stale.len() } + /// A client patched its content live and now runs `version` + /// (`ClientMessage::Content`): recorded for its player only when it is + /// the zone's current version (a client can catch up, never get + /// ahead). Returns whether it was recorded. + pub fn content_patched(&mut self, client: ClientId, version: &str) -> bool { + if version != self.core.config.content_version { + return false; + } + let Some(Viewer::Player(player)) = self.viewer_of(client) else { + return false; + }; + match self.core.players.get_mut(&player) { + Some(slot) => { + slot.content_version = version.to_string(); + true + } + None => false, + } + } + /// Content version a player's client reported. pub fn player_content_version(&self, player: PlayerId) -> Option<&str> { self.core diff --git a/distributed_game/src/net/mod.rs b/distributed_game/src/net/mod.rs index ce957f8b..4582b207 100644 --- a/distributed_game/src/net/mod.rs +++ b/distributed_game/src/net/mod.rs @@ -266,6 +266,11 @@ enum ZoneCmd { client: ClientId, graceful: bool, }, + /// The client patched its content live. + Content { + client: ClientId, + version: String, + }, /// A zone inbox message (decoded into `Z::Message` on the zone task). Deliver { inbound: inbox::WireInbound, @@ -1443,6 +1448,9 @@ fn handle_cmd( conns.remove(&client); host.disconnect(client, graceful); } + ZoneCmd::Content { client, version } => { + host.content_patched(client, &version); + } ZoneCmd::ChildEvent(event) => host.deliver_child_event(event), ZoneCmd::Deliver { inbound, reply } => { let _ = reply.send(inbound.decode::().map(|m| host.deliver(m))); @@ -1624,6 +1632,7 @@ enum Inbound { payload: Vec, }, Ping(f64), + Content(String), } async fn connection(inner: Arc>, zone_id: String, mut socket: WebSocket) @@ -1879,6 +1888,7 @@ where Inbound::Input { seq, client_tick, payload } } Ok(ClientMessage::Ping { client_time }) => Inbound::Ping(client_time), + Ok(ClientMessage::Content { content_version }) => Inbound::Content(content_version), Ok(ClientMessage::Leave) => { graceful = true; break; } Ok(ClientMessage::Hello(_)) => { let _ = socket.send(Message::Binary(error_frame(error::BAD_MESSAGE, "already joined").into())).await; @@ -1973,6 +1983,10 @@ async fn forward_in( }) .await .is_ok(), + Inbound::Content(version) => zone + .send(ZoneCmd::Content { client, version }) + .await + .is_ok(), Inbound::Ping(client_time) => { let pong = ServerMessage::Pong { client_time, diff --git a/distributed_game/src/protocol/messages.rs b/distributed_game/src/protocol/messages.rs index d8024612..78894d92 100644 --- a/distributed_game/src/protocol/messages.rs +++ b/distributed_game/src/protocol/messages.rs @@ -18,6 +18,8 @@ pub mod tag { pub const PING: u8 = 0x03; /// Client → server Leave. pub const LEAVE: u8 = 0x04; + /// Client → server Content (patched live to a content version). + pub const CONTENT: u8 = 0x05; /// Server → client Welcome. pub const WELCOME: u8 = 0x80; /// Server → client Delta. @@ -121,6 +123,13 @@ pub enum ClientMessage { }, /// Graceful leave (no reconnect grace). Leave, + /// The client patched its content live (after a `Resume` with a new + /// version) and now runs `content_version`. Only the zone's current + /// version is accepted; anything else is ignored. + Content { + /// The version the client runs now. + content_version: String, + }, } impl ClientMessage { @@ -152,6 +161,9 @@ impl ClientMessage { Self::Leave => { w.u8(tag::LEAVE); } + Self::Content { content_version } => { + w.u8(tag::CONTENT).string(content_version); + } } w.into_bytes() } @@ -176,6 +188,9 @@ impl ClientMessage { client_time: r.f64()?, }, tag::LEAVE => Self::Leave, + tag::CONTENT => Self::Content { + content_version: r.string()?, + }, other => return Err(DecodeError::UnknownMessage(other)), }; r.finish()?; diff --git a/distributed_game/src/world/content_rollout.rs b/distributed_game/src/world/content_rollout.rs new file mode 100644 index 00000000..bc623473 --- /dev/null +++ b/distributed_game/src/world/content_rollout.rs @@ -0,0 +1,398 @@ +//! A world's live content rollout as a cell (feature `cell`): the barrier +//! the native `WorldCoordinator` runs across hosts, for zones hosted as +//! celld objects, where no process sees every zone. +//! +//! One `ContentRollout` cell per world (kind [`CONTENT_ROLLOUT_KIND`], +//! usually shard `world`) holds the live content version and the phase of a +//! rollout. An admin starts one ([`START`]); the cell then drives itself on +//! its own schedule (`ctx.schedule`): +//! +//! | phase | when | what every zone does (it reads the cell off the tick) | +//! |---|---|---| +//! | [`PHASE_FREEZING`] | [`START`] | freeze (Freeze notice with the change notes) | +//! | [`PHASE_APPLIED`] | `freeze_ms` later ([`APPLY`]) | switch to the new version, resume (Resume notice) | +//! | [`PHASE_RUNNING`] | `settle_ms` later ([`SETTLE`]) | — (zones that slept through it wake on the new version) | +//! +//! Zones apply the version through `ZoneHost::set_content_version` (facts +//! and stamps carry it from then on) and the host's +//! [`StalePolicy`](crate::world::StalePolicy) decides what happens to +//! clients still on the old version (e.g. held until a safe room; a hard +//! rollout reloads them at once). Every phase change is an outward domain +//! event ([`ContentState`]). +//! +//! Unlike the native barrier there is no acknowledgement round: a zone +//! that is not ready to freeze ([`Zone::freeze_ready`](crate::Zone::freeze_ready)) +//! keeps ticking until it is or until the version is applied. + +use std::time::{Duration, SystemTime}; + +use distributed::cell_host::AggregateCell; +use distributed::command::{typed_command, PreparedCommand, Succeeded}; +use distributed::microsvc::{ + CausalCommandContext, CausalRouteDependencies, HandlerError, PortableCommand, Routes, +}; +use distributed::schedule::{schedule_guard, Capability, Schedule}; +use distributed::{sourced, Entity}; +use serde::{Deserialize, Serialize}; + +/// Cell kind (`content-rollout/{world}`). +pub const CONTENT_ROLLOUT_KIND: &str = "content-rollout"; +/// Role an admin's session must carry to start a rollout. +pub const CONTENT_ADMIN_ROLE: &str = "content-admin"; +/// Start a rollout (admin). +pub const START: &str = "content_rollout.start"; +/// Schedule only: apply the new version. +pub const APPLY: &str = "content_rollout.apply"; +/// Schedule only: the rollout is over. +pub const SETTLE: &str = "content_rollout.settle"; +/// No rollout in progress. +pub const PHASE_RUNNING: &str = "running"; +/// Zones freeze. +pub const PHASE_FREEZING: &str = "freezing"; +/// The new version is live; zones resume on it. +pub const PHASE_APPLIED: &str = "applied"; +const ALARM: &str = "rollout"; + +/// One world's content. +#[derive(Clone, Debug, Default, Serialize, Deserialize, distributed::Snapshot)] +pub struct ContentRollout { + entity: Entity, + /// Live content version ("" = the zones' built-in default). + pub version: String, + /// The version being rolled out (during a rollout). + pub target: String, + /// [`PHASE_RUNNING`], [`PHASE_FREEZING`] or [`PHASE_APPLIED`]. + pub phase: String, + /// Change notes of the last rollout. + pub notes: String, + /// When the phase began (cell clock, ms). + pub since_ms: u64, + /// Rollouts so far. + pub rollouts: u32, + /// Hard rollout: stale clients reload at once instead of at a gate. + pub hard: bool, +} + +/// What zones and read models see of the rollout. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, distributed::DomainState)] +#[domain_state(version = 1)] +pub struct ContentState { + /// The world (the cell's stream id). + pub world: String, + /// Live content version. + pub version: String, + /// The version being rolled out. + pub target: String, + /// The phase. + pub phase: String, + /// Change notes. + pub notes: String, + /// When the phase began (ms). + pub since_ms: u64, + /// Hard rollout: every stale client reloads on apply + /// (`ZoneHost::reload_stale_players`); else they keep playing under the + /// zones' `StalePolicy`. + pub hard: bool, +} + +impl From<&ContentRollout> for ContentState { + fn from(c: &ContentRollout) -> Self { + Self { + world: c.entity.id().to_string(), + version: c.version.clone(), + target: c.target.clone(), + phase: if c.phase.is_empty() { + PHASE_RUNNING.to_string() + } else { + c.phase.clone() + }, + notes: c.notes.clone(), + since_ms: c.since_ms, + hard: c.hard, + } + } +} + +#[sourced( + entity, + aggregate_type = "ContentRollout", + events = "ContentRolloutEvent", + domain_state = ContentState +)] +impl ContentRollout { + #[event("content.freezing", version = 1, domain)] + fn freezing(&mut self, world: String, target: String, notes: String, hard: bool, at_ms: u64) { + if self.entity.id().is_empty() { + self.entity.set_id(world); + } + self.hard = hard; + self.target = target; + self.notes = notes; + self.phase = PHASE_FREEZING.to_string(); + self.since_ms = at_ms; + } + + #[event("content.applied", version = 1, domain)] + fn applied(&mut self, at_ms: u64) { + self.version = std::mem::take(&mut self.target); + self.phase = PHASE_APPLIED.to_string(); + self.since_ms = at_ms; + self.rollouts += 1; + } + + #[event("content.settled", version = 1, domain)] + fn settled(&mut self, at_ms: u64) { + self.phase = PHASE_RUNNING.to_string(); + self.since_ms = at_ms; + } +} + +impl ContentRollout { + /// The outward state. + pub fn state(&self) -> ContentState { + ContentState::from(self) + } +} + +/// [`START`] input. +#[derive(Debug, Clone, Serialize, Deserialize, distributed::CommandInput)] +pub struct StartInput { + /// The world (the cell's shard). + pub world: String, + /// Version to roll out. + pub version: String, + /// Change notes shown to players. + pub notes: String, + /// How long zones stay frozen before the version is applied (ms). + pub freeze_ms: u64, + /// Reload every stale client at once (else: at a gate). + pub hard: bool, +} + +/// [`APPLY`] / [`SETTLE`] input. +#[derive(Debug, Clone, Serialize, Deserialize, distributed::CommandInput)] +pub struct PhaseInput { + /// The world. + pub world: String, +} + +/// A rollout command's answer. +#[derive(Debug, Clone, Serialize, Deserialize, distributed::CommandOutput)] +pub struct RolloutReceipt { + /// The phase after the command. + pub phase: String, + /// Live version after the command. + pub version: String, +} + +fn rejected(message: impl std::fmt::Display) -> HandlerError { + HandlerError::Rejected(message.to_string()) +} + +fn now_ms(ctx: &CausalCommandContext<'_, ContentRollout>) -> Result { + ctx.now()? + .duration_since(SystemTime::UNIX_EPOCH) + .map(|now| now.as_millis() as u64) + .map_err(|_| rejected("host clock is before the Unix epoch")) +} + +fn at(world: &str, at_ms: u64, command: &str) -> Result { + Schedule::at( + ALARM, + SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms), + command, + &PhaseInput { + world: world.to_string(), + }, + ) + .map_err(rejected) +} + +/// How long zones keep resuming notices before the rollout is over (ms). +const SETTLE_MS: u64 = 3_000; + +fn is_admin(ctx: &CausalCommandContext<'_, ContentRollout>) -> bool { + ctx.session().has_role(CONTENT_ADMIN_ROLE) +} + +async fn handle_start( + ctx: &CausalCommandContext<'_, ContentRollout>, + input: StartInput, +) -> Result>, HandlerError> { + let version = input.version.trim().to_string(); + if version.is_empty() || version.len() > 64 { + return Err(rejected("version must be 1..=64 characters")); + } + let freeze_ms = input.freeze_ms.clamp(500, 60_000); + let now = now_ms(ctx)?; + let repo = ctx.repo(); + let mut rollout = match repo.get(&input.world).await? { + Some(r) => r, + None => repo.create(), + }; + if !rollout.phase.is_empty() && rollout.phase != PHASE_RUNNING { + return Err(rejected("a rollout is in progress")); + } + if rollout.version == version { + return Err(rejected("that version is live")); + } + let notes: String = input.notes.chars().take(500).collect(); + rollout + .freezing(input.world.clone(), version, notes, input.hard, now) + .map_err(rejected)?; + ctx.schedule(at(&input.world, now + freeze_ms, APPLY)?)?; + let receipt = RolloutReceipt { + phase: rollout.phase.clone(), + version: rollout.version.clone(), + }; + repo.publish_events().commit(rollout)?.succeeded(receipt) +} + +async fn handle_apply( + ctx: &CausalCommandContext<'_, ContentRollout>, + input: PhaseInput, +) -> Result>, HandlerError> { + let now = now_ms(ctx)?; + let repo = ctx.repo(); + let Some(mut rollout) = repo.get(&input.world).await? else { + return Err(rejected("no rollout")); + }; + if rollout.phase != PHASE_FREEZING { + let receipt = RolloutReceipt { + phase: rollout.phase.clone(), + version: rollout.version.clone(), + }; + return repo.commit(rollout)?.succeeded(receipt); + } + rollout.applied(now).map_err(rejected)?; + ctx.schedule(at(&input.world, now + SETTLE_MS, SETTLE)?)?; + let receipt = RolloutReceipt { + phase: rollout.phase.clone(), + version: rollout.version.clone(), + }; + repo.publish_events().commit(rollout)?.succeeded(receipt) +} + +async fn handle_settle( + ctx: &CausalCommandContext<'_, ContentRollout>, + input: PhaseInput, +) -> Result>, HandlerError> { + let now = now_ms(ctx)?; + let repo = ctx.repo(); + let Some(mut rollout) = repo.get(&input.world).await? else { + return Err(rejected("no rollout")); + }; + rollout.settled(now).map_err(rejected)?; + let receipt = RolloutReceipt { + phase: rollout.phase.clone(), + version: rollout.version.clone(), + }; + repo.publish_events().commit(rollout)?.succeeded(receipt) +} + +macro_rules! rollout_command { + ($name:ident, $command:expr, $input:ty, $guard:expr, $handler:expr) => { + struct $name; + impl PortableCommand for $name + where + D: CausalRouteDependencies + Send + Sync + 'static, + { + fn install(self, routes: Routes) -> Routes { + routes + .typed_command(typed_command::<$input, Succeeded>($command)) + .requires(Capability::Scheduling) + .requires(Capability::Clock) + .guarded($guard, $handler) + } + } + }; +} + +rollout_command!(Start, START, StartInput, is_admin, handle_start); +rollout_command!( + Apply, + APPLY, + PhaseInput, + schedule_guard::, + handle_apply +); +rollout_command!( + Settle, + SETTLE, + PhaseInput, + schedule_guard::, + handle_settle +); + +/// Mount every rollout command on a `ContentRollout` cell. +pub fn mount_content_rollout(cell: AggregateCell) -> AggregateCell { + cell.mount(Start).mount(Apply).mount(Settle) +} + +/// What a zone should do for a rollout state, given its own content +/// version and whether it is frozen. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ZoneStep { + /// Nothing to do. + Stay, + /// Freeze with the notes (the rollout began). + Freeze, + /// Switch to `version` (and resume if frozen). + Apply(String), +} + +/// The zone side of the barrier (pure; the celld zone host runs it). +pub fn zone_step(state: &ContentState, zone_version: &str, frozen: bool) -> ZoneStep { + match state.phase.as_str() { + PHASE_FREEZING if !frozen => ZoneStep::Freeze, + PHASE_FREEZING => ZoneStep::Stay, + _ if state.version != zone_version || frozen => ZoneStep::Apply(state.version.clone()), + _ => ZoneStep::Stay, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state(phase: &str, version: &str) -> ContentState { + ContentState { + world: "world".into(), + version: version.into(), + target: String::new(), + phase: phase.into(), + notes: String::new(), + since_ms: 0, + hard: false, + } + } + + #[test] + fn zones_follow_the_phases() { + assert_eq!( + zone_step(&state(PHASE_RUNNING, ""), "", false), + ZoneStep::Stay + ); + assert_eq!( + zone_step(&state(PHASE_FREEZING, ""), "", false), + ZoneStep::Freeze + ); + assert_eq!( + zone_step(&state(PHASE_FREEZING, ""), "", true), + ZoneStep::Stay + ); + assert_eq!( + zone_step(&state(PHASE_APPLIED, "v2"), "", true), + ZoneStep::Apply("v2".into()) + ); + // A zone that slept through the rollout wakes on the new version. + assert_eq!( + zone_step(&state(PHASE_RUNNING, "v2"), "", false), + ZoneStep::Apply("v2".into()) + ); + assert_eq!( + zone_step(&state(PHASE_RUNNING, "v2"), "v2", false), + ZoneStep::Stay + ); + } +} diff --git a/distributed_game/src/world/mod.rs b/distributed_game/src/world/mod.rs index b07d536f..7f1b6787 100644 --- a/distributed_game/src/world/mod.rs +++ b/distributed_game/src/world/mod.rs @@ -20,11 +20,16 @@ //! hibernation slot and activity, projected from the `zone_lease` / //! `zone_state` / `zone_activity` cells' outward events (active / dormant //! / hibernated / cold without opening a cell). +//! - `content_rollout` (feature `cell`): a world's live content rollout +//! (freeze → apply → resume across every celld-hosted zone) as a cell. //! - [`barrier`]: content-version policy types shared by the host and the //! world barrier ([`StalePolicy`], [`BarrierMode`]). pub mod allocation; pub mod barrier; +#[cfg(feature = "cell")] +#[allow(missing_docs)] +pub mod content_rollout; pub mod directory; pub mod ownership; pub mod state; diff --git a/distributed_game/tests/content_rollout/main.rs b/distributed_game/tests/content_rollout/main.rs new file mode 100644 index 00000000..d854ee0e --- /dev/null +++ b/distributed_game/tests/content_rollout/main.rs @@ -0,0 +1,123 @@ +//! The `ContentRollout` cell on the in-process reference host, driven by a +//! `ManualClock`: only a content admin starts a rollout, the cell freezes → +//! applies → settles on its own schedule, one rollout at a time, and a zone +//! following it (`zone_step`) freezes, then switches and resumes. + +use std::time::{Duration, SystemTime}; + +use distributed::cell_host::{ + AggregateCell, CellCommandIdentity, CellDispatchError, CellStreamStore, +}; +use distributed::microsvc::{Session, ROLE_KEY, USER_ID_KEY}; +use distributed::{ManualClock, StreamIdentity}; +use distributed_game::cell::command_id_for; +use distributed_game::world::content_rollout::{ + mount_content_rollout, zone_step, ContentRollout, ZoneStep, CONTENT_ADMIN_ROLE, PHASE_APPLIED, + PHASE_FREEZING, PHASE_RUNNING, START, +}; +use serde_json::{json, Value}; + +const T0: u64 = 1_700_000_000_000; + +fn cell(clock: &ManualClock) -> AggregateCell { + let store = + CellStreamStore::for_identity(StreamIdentity::new("ContentRollout", "world").unwrap()) + .with_clock(clock.clone()); + mount_content_rollout(AggregateCell::from_store(store).unwrap()) +} + +fn session(admin: bool) -> Session { + let mut session = Session::new(); + session.set(USER_ID_KEY, "admin:dev"); + if admin { + session.set(ROLE_KEY, CONTENT_ADMIN_ROLE); + } + session +} + +async fn start( + cell: &AggregateCell, + id: &str, + version: &str, + admin: bool, +) -> Result { + let identity = CellCommandIdentity::new("test", "admin:dev", command_id_for("t", id)).unwrap(); + let input = json!({ + "world": "world", "version": version, "notes": format!("{version} notes"), + "freeze_ms": 2_000, "hard": false, + }); + cell.dispatch_idempotent(START, &identity, input, session(admin)) + .await + .map(|r| r.payload().clone()) +} + +async fn state( + cell: &AggregateCell, +) -> distributed_game::world::content_rollout::ContentState { + cell.load().await.unwrap().expect("rollout").state() +} + +#[tokio::test] +async fn an_admin_rolls_content_out_on_the_cells_own_schedule() { + let clock = ManualClock::new(SystemTime::UNIX_EPOCH + Duration::from_millis(T0)); + let cell = cell(&clock); + + assert!( + start(&cell, "a", "frostfall", false).await.is_err(), + "not a content admin" + ); + let receipt = start(&cell, "b", "frostfall", true).await.unwrap(); + assert_eq!(receipt["phase"], PHASE_FREEZING); + let s = state(&cell).await; + assert_eq!((s.version.as_str(), s.target.as_str()), ("", "frostfall")); + assert_eq!(s.notes, "frostfall notes"); + assert!( + start(&cell, "c", "emberfall", true).await.is_err(), + "one rollout at a time" + ); + // A zone following it freezes, once. + assert_eq!(zone_step(&s, "", false), ZoneStep::Freeze); + assert_eq!(zone_step(&s, "", true), ZoneStep::Stay); + + // The freeze holds for `freeze_ms`, then the version applies. + clock.advance(Duration::from_millis(1_999)); + assert!(cell.fire_due_schedules().await.unwrap().fired.is_empty()); + clock.advance(Duration::from_millis(1)); + assert_eq!(cell.fire_due_schedules().await.unwrap().fired.len(), 1); + let s = state(&cell).await; + assert_eq!(s.phase, PHASE_APPLIED); + assert_eq!(s.version, "frostfall"); + assert_eq!(zone_step(&s, "", true), ZoneStep::Apply("frostfall".into())); + + // …and the rollout settles; a zone that slept through it catches up. + clock.advance(Duration::from_millis(3_000)); + assert_eq!(cell.fire_due_schedules().await.unwrap().fired.len(), 1); + let s = state(&cell).await; + assert_eq!(s.phase, PHASE_RUNNING); + assert_eq!( + zone_step(&s, "", false), + ZoneStep::Apply("frostfall".into()) + ); + assert_eq!(zone_step(&s, "frostfall", false), ZoneStep::Stay); + assert!( + start(&cell, "d", "frostfall", true).await.is_err(), + "that version is live" + ); + start(&cell, "e", "emberfall", true).await.unwrap(); + + // Every phase change left as an outward event. + let events: Vec<_> = cell + .durable_outbox() + .unwrap() + .into_iter() + .map(|m| m.event_type) + .collect(); + for name in ["content.freezing", "content.applied", "content.settled"] { + assert!( + events + .iter() + .any(|e| e.contains(name.split('.').nth(1).unwrap())), + "{name} in {events:?}" + ); + } +} diff --git a/distributed_game/tests/fixtures/messages.json b/distributed_game/tests/fixtures/messages.json index ca36cf34..52e10e9b 100644 --- a/distributed_game/tests/fixtures/messages.json +++ b/distributed_game/tests/fixtures/messages.json @@ -54,6 +54,15 @@ "hex": "04", "name": "leave" }, + { + "decoded": { + "content_version": "c2", + "type": "Content" + }, + "direction": "client_to_server", + "hex": "05026332", + "name": "content" + }, { "decoded": { "content_version": "c1", diff --git a/distributed_game/tests/golden/main.rs b/distributed_game/tests/golden/main.rs index 5ecb5acf..32372c2d 100644 --- a/distributed_game/tests/golden/main.rs +++ b/distributed_game/tests/golden/main.rs @@ -99,6 +99,9 @@ fn client_json(m: &ClientMessage) -> Value { json!({ "type": "Ping", "client_time": client_time }) } ClientMessage::Leave => json!({ "type": "Leave" }), + ClientMessage::Content { content_version } => { + json!({ "type": "Content", "content_version": content_version }) + } } } @@ -347,6 +350,12 @@ fn messages() { }, ), ("leave", ClientMessage::Leave), + ( + "content", + ClientMessage::Content { + content_version: "c2".into(), + }, + ), ]; for (name, msg) in client { let bytes = msg.encode(); diff --git a/examples/game/client/freeze/index.html b/examples/game/client/freeze/index.html deleted file mode 100644 index 9d602545..00000000 --- a/examples/game/client/freeze/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - The Freeze Demo - - - -
    - - - diff --git a/examples/game/client/src/freeze/client.ts b/examples/game/client/src/freeze/client.ts deleted file mode 100644 index 86b29023..00000000 --- a/examples/game/client/src/freeze/client.ts +++ /dev/null @@ -1,277 +0,0 @@ -/** - * The Freeze networking without rendering (Node-testable). - * - * The client "build" runs one content pack (`pack()`), fetched from the - * demo's content CDN (`GET /dev/freeze/content/{version}`, the Rust `Pack` - * record in its payload encoding, decoded by the generated `decodePack`). - * The version-independent layout is the generated `FREEZE_WORLD`. What - * happens at a world barrier: - * - * - `Freeze`: `frozen()` holds the notice; inputs are not sent and are - * counted in `droppedWhileFrozen` (the server also drops and counts any - * that were in flight). - * - `Resume` with a new version: a normal client patches live (fetches the - * pack, `client.setContentVersion`); a **pinned** client (simulated old - * build) keeps its pack and is `stale()` until the server makes it - * reload — at once in a Hard rollout, or when it enters the hub gate in a - * Rolling (safe-room) rollout. - * - `Kick(RELOAD)` / `VERSION_MISMATCH`: `reloadContent` fetches the - * required pack; the SDK reconnects with the reconnect token. - * - `Resume` with reason `content_update_aborted`: `lastResume().aborted`. - */ -import { - type GameClient, - type GameSocketFactory, - type ResumeMessage, - type FreezeMessage -} from '@hops-ops/distributed/game'; - -import * as freeze from '../generated/freeze/index.ts'; -import { zoneUrl, type DevApi } from '../shared/dev-api.ts'; - -const { hub, meadow, ember } = freeze.FREEZE_WORLD; -/** Zone ids (from the generated `FREEZE_WORLD`). */ -export const ZONES = { hub: hub.id, meadow: meadow.id, ember: ember.id } as const; -export const DEFAULT_ZONE = ZONES.hub; - -/** Provenance of an event or fact (generated from the Rust `Stamp` record). */ -export type Stamp = freeze.Stamp; -/** One content pack (generated from the Rust `Pack` record). */ -export type Pack = freeze.Pack; -/** A zone's layout (generated from the Rust `ZoneLayout` record). */ -export type ZoneLayout = freeze.ZoneLayout; - -/** Layout of a zone id (`undefined` for an unknown id). */ -export function zoneLayout(zoneId: string): ZoneLayout | undefined { - return [hub, meadow, ember].find((z) => z.id === zoneId); -} - -export type FreezeHello = freeze.HelloEvent; - -export type LogEntry = Readonly<{ at: number; zone: string; kind: string; text: string; stamp?: Stamp }>; - -export type ResumeInfo = Readonly<{ - version: string; - notes: string[]; - aborted: boolean; - /** Applied live (normal client) vs ignored (pinned client). */ - patched: boolean; - zone: string; -}>; - -/** The content CDN (any version up to the world's current one). */ -export async function fetchPack(base: string, version: string): Promise { - const response = await fetch(`${base}/dev/freeze/content/${encodeURIComponent(version)}`); - if (!response.ok) throw new Error(`content ${version}: HTTP ${response.status}`); - return freeze.decodePack(new Uint8Array(await response.arrayBuffer())); -} - -/** Admin side of the demo (`x-game-admin-secret`). */ -export class FreezeAdmin { - readonly base: string; - secret: string; - - constructor(base: string, secret: string) { - this.base = base; - this.secret = secret; - } - - rollout(mode: 'hard' | 'rolling'): Promise> { - return this.#post('/dev/freeze/rollout', { mode }); - } - - setStuck(zone: string, stuck: boolean): Promise> { - return this.#post('/dev/freeze/stuck', { zone, stuck }); - } - - async world(): Promise { - const response = await fetch(`${this.base}/dev/freeze/world`); - if (!response.ok) throw new Error(`world: HTTP ${response.status}`); - return (await response.json()) as WorldStatus; - } - - async #post(path: string, body: unknown): Promise> { - const response = await fetch(`${this.base}${path}`, { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-game-admin-secret': this.secret }, - body: JSON.stringify(body) - }); - const json = (await response.json().catch(() => ({}))) as Record; - if (!response.ok) throw new Error(`${path}: HTTP ${response.status} ${String(json.error ?? '')}`); - return json; - } -} - -export type ZoneStatus = Readonly<{ - zone: string; - title: string; - state: 'running' | 'freezing' | 'frozen'; - players: number; - droppedFrozen: number; - contentVersion: string; - pack: string; - stuck: boolean; -}>; - -export type WorldStatus = Readonly<{ - version: string; - hosts: readonly Readonly<{ - host: string; - version?: string; - zones?: readonly ZoneStatus[]; - facts?: readonly Readonly<{ command: string; key: string; stamp: Stamp; input: unknown }>[]; - error?: string; - }>[]; - rollouts: readonly Record[]; -}>; - -export type FreezeClient = Readonly<{ - client: GameClient; - /** Content pack the client renders. */ - pack(): Pack; - hello(): FreezeHello | undefined; - /** Resolves on the next hello (each zone sends one), once the predicted step is loaded. */ - nextHello(): Promise; - /** Freeze notice while frozen. */ - frozen(): FreezeMessage | undefined; - lastResume(): ResumeInfo | undefined; - /** World content version (last Welcome / Resume of the authority). */ - worldVersion(): string | undefined; - /** Running older content than the world. */ - stale(): boolean; - /** Inputs not sent because the zone was frozen. */ - droppedWhileFrozen(): number; - /** Content reloads forced by the server (zone, version). */ - reloads: { zone: string; version: string }[]; - log: LogEntry[]; - /** Send one command frame (counted when frozen). */ - sendInput(input: freeze.MoveInput): number | undefined; - onChange(listener: (what: string) => void): () => void; -}>; - -export type FreezeClientOptions = Readonly<{ - api: DevApi; - /** HTTP origin (Node); '' = the page origin. */ - base?: string; - socket?: GameSocketFactory; - /** Simulate an old client: never patch content live. */ - pinned?: () => boolean; -}>; - -export async function createFreezeClient(options: FreezeClientOptions): Promise { - const base = options.base ?? ''; - const pinned = options.pinned ?? (() => false); - let pack = await fetchPack(base, 'current'); - const listeners = new Set<(what: string) => void>(); - const changed = (what: string) => { - for (const listener of listeners) listener(what); - }; - const reloads: { zone: string; version: string }[] = []; - const log: LogEntry[] = []; - const note = (zone: string, kind: string, text: string, stamp?: Stamp) => { - log.push({ at: Date.now(), zone, kind, text, stamp }); - if (log.length > 50) log.shift(); - changed('log'); - }; - let reloadingZone = ''; - const typed = freeze.createFreezeClient({ - url: (zone) => zoneUrl(zone, options.base), - ticket: options.api.ticketSource(), - socket: options.socket, - pingIntervalMs: 500, - inputDtMs: 1000 / 30, - contentVersion: pack.version, - reloadContent: async (request) => { - pack = await fetchPack(base, request.version ?? 'current'); - reloads.push({ zone: reloadingZone, version: pack.version }); - note(reloadingZone, 'reload', `server required content ${pack.version}: reloaded "${pack.name}"`); - changed('pack'); - return pack.version; - } - // Prediction: the generated default, `arena_step` compiled to WASM. - }); - const { client } = typed; - client.on('reloadRequired', (zone) => (reloadingZone = zone)); - - let hello: FreezeHello | undefined; - let helloWaiters: ((h: FreezeHello) => void)[] = []; - let frozen: FreezeMessage | undefined; - let lastResume: ResumeInfo | undefined; - let worldVersion: string | undefined; - let dropped = 0; - - typed.on('hello', (value) => { - hello = value; - client.setControlledEntity(value.entity); - const waiters = helloWaiters; - helloWaiters = []; - for (const w of waiters) w(value); - changed('hello'); - }); - client.on('authority', (_zone, welcome) => { - worldVersion = welcome.contentVersion; - frozen = undefined; - changed('welcome'); - }); - client.on('freeze', (zone, message) => { - if (zone !== client.authority?.zoneId) return; - frozen = message; - note(zone, 'freeze', `world paused for update ${message.version}`); - changed('freeze'); - }); - client.on('resume', (zone, message: ResumeMessage) => { - if (zone !== client.authority?.zoneId) return; - frozen = undefined; - const aborted = message.reason === 'content_update_aborted'; - const notes = message.notes.split('\n').filter((l) => l.length > 0); - worldVersion = message.version; - const update = !aborted && message.version !== client.contentVersion; - const patched = update && !pinned(); - lastResume = { version: message.version, notes, aborted, patched, zone }; - note(zone, aborted ? 'aborted' : 'resume', aborted ? `update aborted (${notes.join(' ')}); still ${message.version}` : `resumed on ${message.version}`); - changed('resume'); - if (patched) { - // A client that can patch live: load the pack, present it from now on. - fetchPack(base, message.version) - .then((next) => { - pack = next; - client.setContentVersion(next.version); - note(zone, 'patch', `patched live to ${next.version} "${next.name}"`); - changed('pack'); - }) - .catch((error: unknown) => note(zone, 'error', String(error))); - } - }); - typed.on('gem', (v, zone) => note(zone, 'gem', `${v.by} collected a gem (+${v.value})`, v.stamp)); - typed.on('burned', (v, zone) => note(zone, 'burned', `${v.name} stepped into lava`, v.stamp)); - typed.on('content', (v, zone) => note(zone, 'content', `zone switched ${v.from} → ${v.to} "${v.name}"`, v.stamp)); - - return { - client, - pack: () => pack, - hello: () => hello, - nextHello: () => - Promise.all([ - new Promise((resolve) => helloWaiters.push(resolve)), - typed.prediction?.ready - ]).then(([h]) => h), - frozen: () => frozen, - lastResume: () => lastResume, - worldVersion: () => worldVersion, - stale: () => worldVersion !== undefined && worldVersion !== client.contentVersion, - droppedWhileFrozen: () => dropped, - reloads, - log, - sendInput(input) { - if (client.authority?.connection.frozen === true) { - dropped += 1; - return undefined; - } - return client.sendInput(input); - }, - onChange(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - } - }; -} diff --git a/examples/game/client/src/freeze/freeze.css b/examples/game/client/src/freeze/freeze.css deleted file mode 100644 index 777719a1..00000000 --- a/examples/game/client/src/freeze/freeze.css +++ /dev/null @@ -1,161 +0,0 @@ -.zone-badge { - position: fixed; - top: 12px; - left: 50%; - transform: translateX(-50%); - padding: 4px 12px; - border-radius: 6px; - background: var(--panel); - pointer-events: none; - white-space: nowrap; -} -.freeze-overlay { - position: fixed; - inset: 0; - display: none; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 6px; - background: rgba(8, 14, 24, 0.72); - backdrop-filter: grayscale(1) blur(1px); - color: #cfe8ff; - text-align: center; - pointer-events: none; -} -.freeze-overlay.show { - display: flex; -} -.freeze-overlay .system { - font-size: 22px; - letter-spacing: 0.3em; - color: #7fd1ff; -} -.freeze-overlay .line { - font-size: 16px; -} -.freeze-overlay .spinner { - width: 28px; - height: 28px; - margin: 8px; - border: 3px solid #2d4a63; - border-top-color: #7fd1ff; - border-radius: 50%; - animation: freeze-spin 0.9s linear infinite; -} -@keyframes freeze-spin { - to { - transform: rotate(360deg); - } -} -.freeze-toast { - position: fixed; - top: 48px; - left: 50%; - transform: translateX(-50%); - max-width: min(560px, calc(100% - 24px)); - padding: 10px 14px; - border-radius: 8px; - background: var(--panel); - border-left: 4px solid #59e08c; - display: none; - pointer-events: none; -} -.freeze-toast.show { - display: block; -} -.freeze-toast.aborted { - border-left-color: #ff5d5d; -} -.freeze-toast.stale { - border-left-color: #ffd84a; -} -.stale-banner { - position: fixed; - bottom: 12px; - left: 50%; - transform: translateX(-50%); - max-width: min(620px, calc(100% - 24px)); - padding: 8px 12px; - border-radius: 8px; - background: rgba(90, 70, 10, 0.9); - color: #ffe9a8; - display: none; - pointer-events: none; -} -.stale-banner.show { - display: block; -} -.freeze-log { - position: fixed; - right: 12px; - top: 48px; - width: 320px; - margin: 0; - padding: 8px 12px 8px 26px; - border-radius: 8px; - background: var(--panel); - font-size: 12px; - pointer-events: none; -} -.freeze-log:empty { - display: none; -} -.freeze-log .aborted, -.freeze-log .burned { - color: #ff8a8a; -} -.freeze-log .reload, -.freeze-log .patch { - color: #7fd1ff; -} -.freeze-log .gem { - color: #fff27a; -} -.hud .admin input.secret { - width: 100%; - box-sizing: border-box; - margin: 4px 0; -} -.hud .admin .buttons { - display: flex; - gap: 6px; - flex-wrap: wrap; -} -.hud .admin button { - font: inherit; - padding: 3px 8px; - cursor: pointer; -} -.hud .admin .result { - display: block; - margin: 4px 0; -} -.hud .admin .zone-row.frozen { - color: #7fd1ff; -} -.hud .admin .zone-row.freezing { - color: #ffd84a; -} -.label.portal { - color: #d7c2ff; -} -.label.ritual { - color: #ff8aa0; -} -.fatal { - position: fixed; - inset: 40px; - color: #ff8a8a; -} -@media (max-width: 640px) { - .freeze-log { - display: none; - } - .zone-badge { - top: auto; - bottom: 52px; - white-space: normal; - max-width: calc(100% - 24px); - } -} diff --git a/examples/game/client/src/freeze/main.ts b/examples/game/client/src/freeze/main.ts deleted file mode 100644 index 5dadb159..00000000 --- a/examples/game/client/src/freeze/main.ts +++ /dev/null @@ -1,407 +0,0 @@ -/** - * The Freeze page: Babylon scene over `createFreezeClient`, plus a dev - * admin panel (needs the admin secret printed by the server; `?admin=`). - * - * Everything that depends on content (pool look, slime color/size, - * crystals, gem value) is derived from the client's *own* pack, so a - * pinned old client visibly keeps the old world while the server already - * runs the new rules. - */ -import type { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial'; -import { Color3 } from '@babylonjs/core/Maths/math.color'; -import type { Mesh } from '@babylonjs/core/Meshes/mesh'; -import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder'; -import { InputPacer, type RenderedEntity } from '@hops-ops/distributed/game'; - -import { ARENA_FLOOR } from '../generated/freeze/constants.ts'; -import { BUTTON, FREEZE_WORLD, KIND, gemFields, playerFields, ritualFields, slimeFields, type KindName, type MoveInput } from '../generated/freeze/index.ts'; -import { DevApi } from '../shared/dev-api.ts'; -import { Hud } from '../shared/hud.ts'; -import { cameraRelative, InputSampler, toAxis } from '../shared/input.ts'; -import { createRing, createStage, material, toWorld } from '../shared/scene.ts'; -import { createFreezeClient, DEFAULT_ZONE, FreezeAdmin, ZONES, zoneLayout, type Pack, type WorldStatus } from './client.ts'; -import './freeze.css'; - -/** The Arena floor, generated from the simulation's constants (`game_sim::arena::arena_floor`). */ -const { half: HALF, playerRadius: PLAYER_RADIUS } = ARENA_FLOOR; - -const params = new URLSearchParams(location.search); -const zone = params.get('zone') ?? DEFAULT_ZONE; -const name = params.get('name') ?? undefined; -let pinned = params.get('old') === '1'; - -function stored(key: string): string | undefined { - try { - return localStorage.getItem(key) ?? undefined; - } catch { - return undefined; - } -} -function store(key: string, value: string): void { - try { - localStorage.setItem(key, value); - } catch { - // private mode - } -} - -const el = (tag: K, className = '', text?: string): HTMLElementTagNameMap[K] => { - const node = document.createElement(tag); - if (className !== '') node.className = className; - if (text !== undefined) node.textContent = text; - return node; -}; - -declare global { - interface Window { - /** Debug handle (console). */ - freeze?: unknown; - } -} - -async function main(): Promise { - const api = new DevApi({ name: () => name }); - const freeze = await createFreezeClient({ api, pinned: () => pinned }); - const { client } = freeze; - - const canvas = document.getElementById('stage') as HTMLCanvasElement; - const stage = createStage(canvas, { half: HALF, background: '#0d1013' }); - const { scene } = stage; - - const hud = new Hud({ - title: 'The Freeze', - hint: 'WASD to move, Space to dash. Walk through portals. Roll out updates in the admin panel; O: simulate an old client.', - client - }); - hud.toggle('Simulate old client (no live patch)', pinned, (on) => (pinned = on), 'o'); - - // ---- DOM overlays ---- - const badge = el('div', 'zone-badge'); - const overlay = el('div', 'freeze-overlay'); - const toast = el('div', 'freeze-toast'); - const banner = el('div', 'stale-banner'); - const logBox = el('ol', 'freeze-log'); - document.body.append(badge, overlay, toast, banner, logBox); - let toastTimer: ReturnType | undefined; - - function showToast(title: string, lines: readonly string[], kind: string): void { - toast.replaceChildren(el('strong', '', title), ...lines.map((l) => el('div', '', `• ${l}`))); - toast.className = `freeze-toast show ${kind}`; - clearTimeout(toastTimer); - toastTimer = setTimeout(() => (toast.className = 'freeze-toast'), 7000); - } - - freeze.onChange((what) => { - if (what === 'resume') { - const r = freeze.lastResume()!; - if (r.aborted) showToast(`SYSTEM: update aborted — the world stays on ${r.version}`, r.notes, 'aborted'); - else if (r.patched || !pinned) showToast(`SYSTEM: update ${r.version} applied. Here's what changed:`, r.notes.slice(1), 'applied'); - else showToast(`SYSTEM: the world now runs ${r.version}`, ['Your old client keeps its content until you enter the Town Hub.', ...r.notes.slice(1)], 'stale'); - } - if (what === 'pack' && freeze.reloads.length > 0 && freeze.reloads.at(-1)!.version === freeze.pack().version) { - const p = freeze.pack(); - showToast(`SYSTEM: content reloaded — ${p.name} (${p.version})`, p.notes, 'applied'); - } - if (what === 'log') { - const e = freeze.log.at(-1)!; - const li = el('li', e.kind, `${e.text}${e.stamp === undefined ? '' : ` · ${e.stamp.zone}#${e.stamp.tick} @${e.stamp.contentVersion}`}`); - logBox.prepend(li); - while (logBox.children.length > 8) logBox.lastChild?.remove(); - } - }); - - // ---- admin panel ---- - // `?admin=` is remembered, so other tabs (e.g. `?old=1`) can roll out too. - const adminParam = params.get('admin'); - if (adminParam !== null) store('game-demos.freeze.admin', adminParam); - const admin = new FreezeAdmin('', adminParam ?? stored('game-demos.freeze.admin') ?? ''); - const panel = el('fieldset', 'admin'); - panel.append(el('legend', '', 'World admin (dev, admin secret)')); - const secret = el('input', 'secret'); - secret.type = 'password'; - secret.placeholder = 'admin secret (printed by the server)'; - secret.value = admin.secret; - secret.addEventListener('input', () => { - admin.secret = secret.value.trim(); - store('game-demos.freeze.admin', admin.secret); - }); - const buttons = el('div', 'buttons'); - const hardButton = el('button', '', 'Roll out update (Hard)'); - const rollingButton = el('button', '', 'Roll out (Safe room)'); - buttons.append(hardButton, rollingButton); - const ritualLabel = el('label', 'toggle'); - const ritual = el('input'); - ritual.type = 'checkbox'; - ritualLabel.append(ritual, document.createTextNode(' Ritual in Ember Fields (zone refuses to freeze)')); - const result = el('small', 'result', ''); - const status = el('div', 'world-status'); - panel.append(secret, buttons, ritualLabel, result, status); - hud.root.append(panel); - - function needSecret(): boolean { - if (admin.secret !== '') return false; - result.textContent = 'Paste the admin secret printed by the server (or open /freeze/?admin=).'; - secret.focus(); - return true; - } - - async function roll(mode: 'hard' | 'rolling'): Promise { - if (needSecret()) return; - hardButton.disabled = rollingButton.disabled = true; - result.textContent = `rolling out (${mode})…`; - try { - const r = await admin.rollout(mode); - result.textContent = r.ok - ? `✓ ${String(r.from)} → ${String(r.version)} "${String(r.name)}" in ${String(r.tookMs)} ms` - : `✗ aborted (${String(r.error)}: ${(r.missing as string[] | undefined)?.join(', ') ?? ''}) — still ${String(r.version)}`; - } catch (error) { - result.textContent = String(error); - } finally { - hardButton.disabled = rollingButton.disabled = false; - } - } - hardButton.addEventListener('click', () => void roll('hard')); - rollingButton.addEventListener('click', () => void roll('rolling')); - ritual.addEventListener('change', () => { - if (needSecret()) { - ritual.checked = !ritual.checked; - return; - } - admin.setStuck(ZONES.ember, ritual.checked).then( - () => (result.textContent = ritual.checked ? 'Ember Fields will refuse the next freeze' : 'ritual ended'), - (error: unknown) => { - result.textContent = String(error); - ritual.checked = !ritual.checked; - } - ); - }); - - let world: WorldStatus | undefined; - async function pollWorld(): Promise { - try { - world = await admin.world(); - status.replaceChildren( - ...world.hosts.flatMap((h) => - (h.zones ?? []).map((z) => - el( - 'div', - `zone-row ${z.state}`, - `${h.host} · ${z.title.replace(' (safe room)', '')}: ${z.state} ${z.contentVersion} · ${z.players}p · dropped ${z.droppedFrozen}${z.stuck ? ' · RITUAL' : ''}` - ) - ) - ) - ); - ritual.checked = world.hosts.some((h) => h.zones?.some((z) => z.zone === ZONES.ember && z.stuck)); - } catch { - status.textContent = 'world status unavailable'; - } - } - setInterval(() => void pollWorld(), 1000); - void pollWorld(); - - // ---- content-derived scenery ---- - const mats = { - me: material(scene, 'me', '#6cc4ff'), - other: material(scene, 'other', '#f2a65a'), - pillar: material(scene, 'pillar', '#7b8798'), - portal: material(scene, 'portal', '#b48cff'), - gem: material(scene, 'gem', '#fff27a') - }; - mats.portal.emissiveColor = Color3.FromHexString('#5b3fa8'); - mats.gem.emissiveColor = Color3.FromHexString('#8a7d1e'); - for (const { x, y, r } of ARENA_FLOOR.pillars) { - const p = MeshBuilder.CreateCylinder('pillar', { diameter: r * 2, height: 2, tessellation: 24 }, scene); - p.position = toWorld(x, y, 1); - p.material = mats.pillar; - } - let scenery: Mesh[] = []; - let sceneryKey = ''; - let slimeMat: StandardMaterial = material(scene, 'slime', '#7ed957'); - - function buildScenery(pack: Pack, zoneId: string): void { - for (const m of scenery) m.dispose(); - for (const key of [...portalLabels]) stage.labels.delete(key); - portalLabels.clear(); - scenery = []; - const layout = zoneLayout(zoneId); - if (layout === undefined) return; - const lava = pack.hazard === 'lava'; - const pool = material(scene, `pool-${pack.version}`, lava ? '#ff5a1f' : '#bfe9ff', lava ? 1 : 0.85); - pool.emissiveColor = Color3.FromHexString(lava ? '#a8320c' : '#3d6f8a'); - for (const p of layout.pools) { - const disc = MeshBuilder.CreateDisc('pool', { radius: p.r, tessellation: 40 }, scene); - disc.rotation.x = Math.PI / 2; - disc.position = toWorld(p.x, p.y, 0.02); - disc.material = pool; - scenery.push(disc); - } - for (const p of layout.portals) { - const ring = MeshBuilder.CreateTorus('portal', { diameter: 2.4, thickness: 0.25, tessellation: 32 }, scene); - ring.rotation.z = Math.PI / 2; - ring.position = toWorld(p.x, p.y, 1.3); - ring.material = mats.portal; - scenery.push(ring); - const key = `portal:${p.to}`; - portalLabels.add(key); - stage.labels.set(key, ring, `→ ${p.title}`, 1.6, 'portal'); - } - const crystal = material(scene, `crystal-${pack.version}`, pack.crystalColor); - crystal.emissiveColor = Color3.FromHexString(pack.crystalColor).scale(0.35); - for (const { x, y } of FREEZE_WORLD.crystals[pack.crystalLayout] ?? []) { - const c = MeshBuilder.CreateCylinder('crystal', { diameterTop: 0, diameterBottom: 1.1, height: 2.6, tessellation: 5 }, scene); - c.position = toWorld(x, y, 1.3); - c.material = crystal; - scenery.push(c); - } - slimeMat = material(scene, `slime-${pack.version}`, pack.slimeColor); - for (const v of visuals.values()) if (v.kind === 'slime') v.mesh.material = slimeMat; - } - const portalLabels = new Set(); - - // ---- entities ---- - type Visual = { mesh: Mesh; kind: KindName }; - const kindOf = (entity: RenderedEntity): KindName => - (Object.keys(KIND) as KindName[]).find((k) => KIND[k] === entity.kind) ?? 'player'; - const visuals = new Map(); - const ritualRing = createRing(scene, 2, '#ff4d6d'); - ritualRing.setEnabled(false); - - function visualFor(entity: RenderedEntity, mine: boolean): Visual { - let v = visuals.get(entity.key); - if (v !== undefined) return v; - let mesh: Mesh; - const kind = kindOf(entity); - switch (kind) { - case 'slime': - mesh = MeshBuilder.CreateSphere(entity.key, { diameter: 0.9, segments: 10 }, scene); - mesh.scaling.y = 0.6; - mesh.material = slimeMat; - break; - case 'gem': - mesh = MeshBuilder.CreatePolyhedron(entity.key, { type: 1, size: 0.35 }, scene); - mesh.material = mats.gem; - break; - case 'ritual': - mesh = MeshBuilder.CreateBox(entity.key, { size: 0.01 }, scene); - mesh.isVisible = false; - break; - default: - mesh = MeshBuilder.CreateCapsule(entity.key, { radius: PLAYER_RADIUS, height: 1.8 }, scene); - mesh.material = mine ? mats.me : mats.other; - } - v = { mesh, kind }; - visuals.set(entity.key, v); - return v; - } - - // ---- input ---- - const sampler = new InputSampler(); - const frames = new InputPacer({ tickRate: () => client.authority?.connection.welcome?.tickRate }); - let lastInput: MoveInput = { mx: 0, my: 0, buttons: 0 }; - const sendFrame = () => { - const s = sampler.sample(); - const move = cameraRelative(s, stage.cameraForward()); - lastInput = { mx: toAxis(move.x), my: toAxis(move.y), buttons: s.action ? BUTTON.dash : 0 }; - freeze.sendInput(lastInput); - }; - - stage.onFrame((now, dtMs) => { - frames.update(now, sendFrame); - const hello = freeze.hello(); - const pack = freeze.pack(); - const authority = client.authority; - const zoneId = hello?.zone ?? authority?.zoneId ?? zone; - if (sceneryKey !== `${pack.version}:${zoneId}`) { - sceneryKey = `${pack.version}:${zoneId}`; - buildScenery(pack, zoneId); - } - const rendered = client.render(); - const seen = new Set(); - let me: { x: number; y: number } | undefined; - let ritualStuck = false; - for (const entity of rendered) { - if (authority !== undefined && entity.zoneId !== authority.zoneId) continue; - const mine = hello !== undefined && entity.id === hello.entity; - const v = visualFor(entity, mine); - seen.add(entity.key); - switch (v.kind) { - case 'slime': { - const { x, y } = slimeFields(entity); - v.mesh.position.copyFrom(toWorld(x, y, 0.3 * pack.slimeScale)); - v.mesh.scaling.set(pack.slimeScale, 0.6 * pack.slimeScale, pack.slimeScale); - break; - } - case 'gem': { - const { x, y } = gemFields(entity); - v.mesh.position.copyFrom(toWorld(x, y, 0.8 + 0.15 * Math.sin(now / 300))); - v.mesh.rotation.y = now / 600; - break; - } - case 'ritual': { - const { x, y, stuck } = ritualFields(entity); - v.mesh.position.copyFrom(toWorld(x, y, 0)); - ritualStuck = stuck === 1; - ritualRing.position.copyFrom(toWorld(x, y, 0)); - if (ritualStuck) stage.labels.set('ritual', v.mesh, 'RITUAL IN PROGRESS — this zone cannot pause', 0.6, 'ritual'); - else stage.labels.delete('ritual'); - break; - } - default: { - const { x, y, name } = playerFields(entity); - v.mesh.position.copyFrom(toWorld(x, y, 0.9)); - stage.labels.set(entity.key, v.mesh, mine ? `${name} (you)` : name, 1.4, mine ? 'me' : ''); - if (mine) me = { x, y }; - } - } - v.mesh.setEnabled(!entity.stale); - } - ritualRing.setEnabled(ritualStuck); - ritualRing.scaling.setAll(1 + 0.08 * Math.sin(now / 150)); - for (const [key, v] of visuals) { - if (seen.has(key)) continue; - v.mesh.dispose(); - stage.labels.delete(key); - visuals.delete(key); - } - if (me !== undefined) stage.follow(me.x, me.y, dtMs); - - // Overlays. - const frozen = freeze.frozen(); - overlay.classList.toggle('show', frozen !== undefined); - if (frozen !== undefined) { - const title = frozen.notes.split('\n')[0] ?? ''; - overlay.replaceChildren( - el('div', 'system', 'SYSTEM'), - el('div', 'line', 'An administrator has paused the world…'), - el('div', 'line', `applying update ${frozen.version}${title === '' ? '' : ` — ${title}`}`), - el('div', 'spinner'), - el('small', '', `inputs dropped while frozen: ${freeze.droppedWhileFrozen()}`) - ); - } - const stale = freeze.stale() && pinned; - banner.classList.toggle('show', stale); - if (stale) { - banner.textContent = hello?.gate - ? `Your client runs ${client.contentVersion}; the world runs ${freeze.worldVersion()}. Leave and re-enter the hub to update.` - : `Your client runs ${client.contentVersion} (old build); the world runs ${freeze.worldVersion()}. Keep playing — walk into the Town Hub (safe room) to update.`; - } - badge.textContent = hello === undefined ? 'connecting…' : `${hello.title} · host ${hello.host} · content ${client.contentVersion} "${pack.name}"`; - - hud.frame({ entities: rendered.length }); - const serverDropped = world?.hosts.reduce((n, h) => n + (h.zones ?? []).reduce((m, z) => m + z.droppedFrozen, 0), 0); - hud.row('You', api.last === undefined ? '—' : api.last.name); - hud.row('Client content', `${client.contentVersion} ${pinned ? '(pinned old client)' : '(live patching)'}`); - hud.row('World content', freeze.worldVersion() ?? '—'); - hud.row('Pools', pack.hazard === 'lava' ? 'lava (burns)' : 'ice (safe)'); - hud.row('Inputs dropped', `client ${freeze.droppedWhileFrozen()} · server ${serverDropped ?? '—'}`); - hud.row('Reloads', freeze.reloads.map((r) => `${r.version}@${r.zone.replace('freeze-', '')}`).join(', ') || '—'); - }); - - window.freeze = { freeze, client, api, admin, scene }; - - await client.connect(zone).catch((error: unknown) => hud.row('Status', `failed: ${String(error)}`)); -} - -main().catch((error: unknown) => { - document.body.append(el('pre', 'fatal', `The Freeze failed to start: ${String(error)}\n(is the server running? ./run.sh freeze)`)); -}); diff --git a/examples/game/client/src/generated/freeze/client.ts b/examples/game/client/src/generated/freeze/client.ts deleted file mode 100644 index 79e78f6d..00000000 --- a/examples/game/client/src/generated/freeze/client.ts +++ /dev/null @@ -1,68 +0,0 @@ -// @generated by `distributed game-client` from game surface `freeze` (schema v3, protocol v1). -// Typed client factory. -// Do not edit: change the Rust definitions and regenerate. -import { GameClient, mergeCorrections, type GameClientOptions, type GamePredictionOptions, type WasmSource } from '@hops-ops/distributed/game'; - -import { EVENTS, type Events } from './events.ts'; -import { encodeInput, type MoveInput } from './input.ts'; -import { createFreezePrediction, type FreezePrediction } from './predict.ts'; -import { CORRECTIONS } from './schema.ts'; - -/** - * Prediction tuning; `encodeInput`, the correction defaults and `predict` (the - * compiled `#[predict]` step) are filled in. A `predict` given here replaces - * the WebAssembly step. - */ -export type FreezePredictionOptions = Omit, 'encodeInput' | 'predict'> & - Partial, 'encodeInput' | 'predict'>>; - -export type FreezeClientOptions = Omit, 'prediction'> & - Readonly<{ - /** Prediction tuning, or `false` for none. Default: the compiled step. */ - prediction?: FreezePredictionOptions | false; - /** Where to load the compiled step from (default `PREDICT_WASM_URL`). */ - wasm?: WasmSource; - }>; - -export type FreezeClient = Readonly<{ - client: GameClient; - /** The compiled step (`ready`, `setContext`); undefined when prediction is off or custom. */ - prediction: FreezePrediction | undefined; - /** Typed event subscription (every zone); returns the unsubscribe. */ - on(name: K, handler: (value: Events[K], zoneId: string) => void): () => void; - /** Send one input: predicted when prediction is on, raw otherwise. */ - send(input: MoveInput): number | undefined; -}>; - -/** - * A `GameClient` for the `freeze` surface with its input encoder, correction - * defaults and the compiled predicted step wired in. - */ -export function createFreezeClient(options: FreezeClientOptions): FreezeClient { - const { prediction: tuning, wasm, ...rest } = options; - const prediction = - tuning === false || tuning?.predict !== undefined ? undefined : createFreezePrediction(wasm); - const client = new GameClient({ - ...rest, - prediction: - tuning === false - ? undefined - : { - ...tuning, - predict: tuning?.predict ?? prediction!.predict, - encodeInput: tuning?.encodeInput ?? ((input) => encodeInput(input)), - corrections: mergeCorrections(CORRECTIONS, tuning?.corrections) - } - }); - return { - client, - prediction, - on: (name, handler) => - client.on('event', (zoneId, event) => { - const codec = EVENTS[name]; - if (event.typeId === codec.id) handler(codec.decode(event.payload), zoneId); - }), - send: (input) => - tuning === false ? client.sendRawInput(encodeInput(input)) : client.sendInput(input) - }; -} diff --git a/examples/game/client/src/generated/freeze/constants.ts b/examples/game/client/src/generated/freeze/constants.ts deleted file mode 100644 index 27149e3e..00000000 --- a/examples/game/client/src/generated/freeze/constants.ts +++ /dev/null @@ -1,93 +0,0 @@ -// @generated by `distributed game-client` from game surface `freeze` (schema v3, protocol v1). -// Constants: static data defined once in Rust (`GameSurface::constant`). -// Do not edit: change the Rust definitions and regenerate. -import type { ArenaFloor, FreezeWorld } from './events.ts'; - -/** `arena_floor`, as the client decodes it. */ -export const ARENA_FLOOR: ArenaFloor = { - half: 20, - playerRadius: 0.5, - dashCooldown: 45, - pillars: [ - { x: 0, y: 0, r: 2.5 }, - { x: -9, y: -9, r: 1.5 }, - { x: 9, y: -9, r: 1.5 }, - { x: -9, y: 9, r: 1.5 }, - { x: 9, y: 9, r: 1.5 }, - { x: 0, y: -14, r: 1 }, - { x: 0, y: 14, r: 1 }, - { x: -14, y: 0, r: 1 }, - { x: 14, y: 0, r: 1 } - ] -}; - -/** `freeze_world`, as the client decodes it. */ -export const FREEZE_WORLD: FreezeWorld = { - hub: { - id: 'freeze-hub', - title: 'Town Hub (safe room)', - gate: true, - portals: [ - { x: -16.5, y: 5, to: 'freeze-meadow', title: 'Whisper Meadow' }, - { x: 16.5, y: 5, to: 'freeze-ember', title: 'Ember Fields' } - ], - pools: [], - ritual: { x: 0, y: -10 } - }, - meadow: { - id: 'freeze-meadow', - title: 'Whisper Meadow', - gate: false, - portals: [ - { x: 16.5, y: 5, to: 'freeze-hub', title: 'Town Hub (safe room)' } - ], - pools: [ - { x: -6, y: 6, r: 2.200000047683716 }, - { x: 6, y: -5, r: 2.5 } - ], - ritual: { x: 0, y: -10 } - }, - ember: { - id: 'freeze-ember', - title: 'Ember Fields', - gate: false, - portals: [ - { x: -16.5, y: 5, to: 'freeze-hub', title: 'Town Hub (safe room)' } - ], - pools: [ - { x: -5, y: -5, r: 2.5 }, - { x: 6, y: 5, r: 3 }, - { x: -4, y: 12, r: 2 } - ], - ritual: { x: 0, y: -10 } - }, - crystals: [ - [ - { x: -17, y: -17 }, - { x: -11, y: -17.5 }, - { x: 17, y: -16 }, - { x: 17.5, y: 11 }, - { x: -17.5, y: 15 }, - { x: -8, y: 17 }, - { x: 10, y: 17.5 } - ], - [ - { x: 17, y: -17 }, - { x: 11, y: -17.5 }, - { x: -17, y: -16 }, - { x: -17.5, y: 11 }, - { x: 17.5, y: 15 }, - { x: 8, y: 17 }, - { x: -10, y: 17.5 } - ], - [ - { x: -17, y: -17 }, - { x: -17.5, y: -11 }, - { x: -16, y: 17 }, - { x: 11, y: 17.5 }, - { x: 15, y: -17.5 }, - { x: 17, y: -8 }, - { x: 17.5, y: 10 } - ] - ] -}; diff --git a/examples/game/client/src/generated/freeze/events.ts b/examples/game/client/src/generated/freeze/events.ts deleted file mode 100644 index ca32e054..00000000 --- a/examples/game/client/src/generated/freeze/events.ts +++ /dev/null @@ -1,245 +0,0 @@ -// @generated by `distributed game-client` from game surface `freeze` (schema v3, protocol v1). -// Events and records: payload interfaces, ids, codecs, typed subscriptions. -// Do not edit: change the Rust definitions and regenerate. -import { decodeGameEvent, decodePayload, decodeValue, encodePayload, encodeValue, onGameEvent, type DecodedGameEvent, type GameEventCodecs, type GameEventSource, type PayloadLayout, type WireType } from '@hops-ops/distributed/game'; - -/** Record `Pillar` (`#[derive(GameRecord)]`). */ -export interface Pillar { - readonly x: number; - readonly y: number; - readonly r: number; -} - -/** Record `ArenaFloor` (`#[derive(GameRecord)]`). */ -export interface ArenaFloor { - readonly half: number; - readonly playerRadius: number; - readonly dashCooldown: number; - readonly pillars: readonly Pillar[]; -} - -/** Record `Portal` (`#[derive(GameRecord)]`). */ -export interface Portal { - readonly x: number; - readonly y: number; - readonly to: string; - readonly title: string; -} - -/** Record `Pool` (`#[derive(GameRecord)]`). */ -export interface Pool { - readonly x: number; - readonly y: number; - readonly r: number; -} - -/** Record `Spot` (`#[derive(GameRecord)]`). */ -export interface Spot { - readonly x: number; - readonly y: number; -} - -/** Record `ZoneLayout` (`#[derive(GameRecord)]`). */ -export interface ZoneLayout { - readonly id: string; - readonly title: string; - readonly gate: boolean; - readonly portals: readonly Portal[]; - readonly pools: readonly Pool[]; - readonly ritual: Spot; -} - -/** Record `FreezeWorld` (`#[derive(GameRecord)]`). */ -export interface FreezeWorld { - readonly hub: ZoneLayout; - readonly meadow: ZoneLayout; - readonly ember: ZoneLayout; - readonly crystals: readonly (readonly Spot[])[]; -} - -/** Record `MoveState` (`#[derive(GameRecord)]`). */ -export interface MoveState { - readonly x: number; - readonly y: number; - readonly vx: number; - readonly vy: number; - readonly dash: number; - readonly cd: number; -} - -/** Record `Pack` (`#[derive(GameRecord)]`). */ -export interface Pack { - readonly version: string; - readonly name: string; - readonly notes: readonly string[]; - readonly hazard: string; - readonly slimeSpeed: number; - readonly slimeColor: string; - readonly slimeScale: number; - readonly crystalColor: string; - readonly crystalLayout: number; - readonly gemValue: number; -} - -const PACK_TYPE: WireType = { - type: 'record', - fields: [ - ['version', { type: 'short_str' }], - ['name', { type: 'short_str' }], - ['notes', { type: 'list', item: { type: 'short_str' } }], - ['hazard', { type: 'short_str' }], - ['slimeSpeed', { type: 'f32' }], - ['slimeColor', { type: 'short_str' }], - ['slimeScale', { type: 'f32' }], - ['crystalColor', { type: 'short_str' }], - ['crystalLayout', { type: 'u8' }], - ['gemValue', { type: 'u32' }], - ] -}; - -/** Record `Stamp` (`#[derive(GameRecord)]`). */ -export interface Stamp { - readonly zone: string; - readonly tick: number; - readonly contentVersion: string; -} - -const STAMP_TYPE: WireType = { - type: 'record', - fields: [ - ['zone', { type: 'short_str' }], - ['tick', { type: 'u64' }], - ['contentVersion', { type: 'short_str' }], - ] -}; - -/** Encode a `Pack` record (standalone codec, e.g. served over HTTP). */ -export function encodePack(value: Pack): Uint8Array { - return encodeValue(PACK_TYPE, value); -} - -/** Decode a whole `Pack` record (throws on trailing or missing bytes). */ -export function decodePack(bytes: Uint8Array): Pack { - return decodeValue(PACK_TYPE, bytes) as Pack; -} - -/** Event `hello` (id 1). */ -export interface HelloEvent { - readonly entity: number; - readonly zone: string; - readonly title: string; - readonly gate: boolean; - readonly host: string; - readonly half: number; - readonly contentVersion: string; -} - -const HELLO_LAYOUT: PayloadLayout = [ - ['entity', { type: 'u32' }], - ['zone', { type: 'short_str' }], - ['title', { type: 'short_str' }], - ['gate', { type: 'bool' }], - ['host', { type: 'short_str' }], - ['half', { type: 'f32' }], - ['contentVersion', { type: 'short_str' }], -]; - -/** Event `gem` (id 2). */ -export interface GemEvent { - readonly by: string; - readonly value: number; - readonly stamp: Stamp; -} - -const GEM_LAYOUT: PayloadLayout = [ - ['by', { type: 'short_str' }], - ['value', { type: 'u32' }], - ['stamp', STAMP_TYPE], -]; - -/** Event `burned` (id 3). */ -export interface BurnedEvent { - readonly name: string; - readonly stamp: Stamp; -} - -const BURNED_LAYOUT: PayloadLayout = [ - ['name', { type: 'short_str' }], - ['stamp', STAMP_TYPE], -]; - -/** Event `content` (id 4). */ -export interface ContentEvent { - readonly from: string; - readonly to: string; - readonly name: string; - readonly stamp: Stamp; -} - -const CONTENT_LAYOUT: PayloadLayout = [ - ['from', { type: 'short_str' }], - ['to', { type: 'short_str' }], - ['name', { type: 'short_str' }], - ['stamp', STAMP_TYPE], -]; - -/** Event type ids by name. */ -export const EVENT = { - hello: 1, - gem: 2, - burned: 3, - content: 4, -} as const; - -/** Event payload types by name. */ -export interface Events { - hello: HelloEvent; - gem: GemEvent; - burned: BurnedEvent; - content: ContentEvent; -} - -/** Codecs of every event. */ -export const EVENTS: GameEventCodecs = { - hello: { - id: 1, - name: 'hello', - encode: (value) => encodePayload(HELLO_LAYOUT, value), - decode: (payload) => decodePayload(HELLO_LAYOUT, payload) as unknown as HelloEvent - }, - gem: { - id: 2, - name: 'gem', - encode: (value) => encodePayload(GEM_LAYOUT, value), - decode: (payload) => decodePayload(GEM_LAYOUT, payload) as unknown as GemEvent - }, - burned: { - id: 3, - name: 'burned', - encode: (value) => encodePayload(BURNED_LAYOUT, value), - decode: (payload) => decodePayload(BURNED_LAYOUT, payload) as unknown as BurnedEvent - }, - content: { - id: 4, - name: 'content', - encode: (value) => encodePayload(CONTENT_LAYOUT, value), - decode: (payload) => decodePayload(CONTENT_LAYOUT, payload) as unknown as ContentEvent - }, -}; - -/** A decoded event, discriminated by `name`. */ -export type AnyEvent = DecodedGameEvent; - -/** Decode an Event message (`undefined` for ids this surface does not declare). */ -export function decodeEvent(event: { readonly typeId: number; readonly payload: Uint8Array }): AnyEvent | undefined { - return decodeGameEvent(EVENTS, event); -} - -/** Subscribe to one typed event on every zone of `client`; returns the unsubscribe. */ -export function onEvent( - client: GameEventSource, - name: K, - handler: (value: Events[K], zoneId: string) => void -): () => void { - return onGameEvent(client, EVENTS, name, handler); -} diff --git a/examples/game/client/src/generated/freeze/index.ts b/examples/game/client/src/generated/freeze/index.ts deleted file mode 100644 index 728a97c9..00000000 --- a/examples/game/client/src/generated/freeze/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @generated by `distributed game-client` from game surface `freeze` (schema v3, protocol v1). -// Everything generated for this surface. -// Do not edit: change the Rust definitions and regenerate. -export * from './client.ts'; -export * from './constants.ts'; -export * from './events.ts'; -export * from './input.ts'; -export * from './predict.ts'; -export * from './schema.ts'; diff --git a/examples/game/client/src/generated/freeze/input.ts b/examples/game/client/src/generated/freeze/input.ts deleted file mode 100644 index f4050e86..00000000 --- a/examples/game/client/src/generated/freeze/input.ts +++ /dev/null @@ -1,51 +0,0 @@ -// @generated by `distributed game-client` from game surface `freeze` (schema v3, protocol v1). -// Input frame: interface, button bits, encoder, decoder, sanitize. -// Do not edit: change the Rust definitions and regenerate. -import { decodePayload, encodePayload, sanitizePayload, type InputRule, type PayloadLayout } from '@hops-ops/distributed/game'; - -/** One `MoveInput` input frame (`#[derive(GameInput)]`). */ -export interface MoveInput { - readonly mx: number; - readonly my: number; - readonly buttons: number; -} - -export type Input = MoveInput; - -/** Bits of each bit-flag field (`#[buttons(..)]`), by field. */ -export const BITS = { - buttons: { - dash: 1, - }, -} as const; - -/** Bits of `buttons`, the only bit-flag field. */ -export const BUTTON = BITS.buttons; - -/** Wire layout, in field order. */ -export const INPUT_LAYOUT: PayloadLayout = [ - ['mx', { type: 'i8' }], - ['my', { type: 'i8' }], - ['buttons', { type: 'u8' }], -]; - -const INPUT_RULES: readonly InputRule[] = [ - { range: [-127, 127] }, - { range: [-127, 127] }, - { buttons: 1 }, -]; - -/** Payload bytes of one input frame. */ -export function encodeInput(input: MoveInput): Uint8Array { - return encodePayload(INPUT_LAYOUT, input); -} - -/** Decode one input frame (no sanitizing). */ -export function decodeInput(payload: Uint8Array): MoveInput { - return decodePayload(INPUT_LAYOUT, payload) as unknown as MoveInput; -} - -/** What the zone simulates for `input` (`#[range]` clamps, `#[buttons]` masks, wire precision). */ -export function sanitizeInput(input: MoveInput): MoveInput { - return sanitizePayload(INPUT_LAYOUT, INPUT_RULES, input)!; -} diff --git a/examples/game/client/src/generated/freeze/predict.ts b/examples/game/client/src/generated/freeze/predict.ts deleted file mode 100644 index c4d44dee..00000000 --- a/examples/game/client/src/generated/freeze/predict.ts +++ /dev/null @@ -1,51 +0,0 @@ -// @generated by `distributed game-client` from game surface `freeze` (schema v3, protocol v1). -// Client-predicted step: the Rust `#[predict]` fn compiled to WebAssembly (binary ABI). -// Do not edit: change the Rust definitions and regenerate. -import { createWasmPrediction, type PredictStateLayout, type WasmPrediction, type WasmSource } from '@hops-ops/distributed/game'; - -import { encodeInput, type MoveInput } from './input.ts'; - -/** The predicted step: `#[predict]` fn `arena_step` of Cargo package `game_sim`, predicting kind `player`. */ -export const PREDICT = { - kind: 'player', - export: 'arena_step', - package: 'game_sim', - state: 'MoveState', - abi: 1, -} as const; - -/** Predicted `player` fields (record `MoveState`): wire field name and codec type, in record order. */ -export const PREDICT_STATE: PredictStateLayout = [ - ['x', { type: 'f32' }], - ['y', { type: 'f32' }], - ['vx', { type: 'f32' }], - ['vy', { type: 'f32' }], - ['dash', { type: 'u8' }], - ['cd', { type: 'u8' }], -]; - -/** The step takes no context. */ -export type PredictContext = undefined; - -/** The step takes no context. */ -export const PREDICT_CONTEXT = undefined; - -/** The compiled step (`distributed game-wasm`); bundlers emit it as an asset. */ -export const PREDICT_WASM_URL: URL = new URL('./wasm/freeze.wasm', import.meta.url); - -/** The `freeze` step as `GameClient` prediction (see `createWasmPrediction`). */ -export type FreezePrediction = WasmPrediction; - -/** - * Load the compiled step (default `PREDICT_WASM_URL`) as prediction: `predict` - * leaves the state unchanged until `ready` resolves. - */ -export function createFreezePrediction(source: WasmSource = PREDICT_WASM_URL): FreezePrediction { - return createWasmPrediction({ - source, - exportName: PREDICT.export, - state: PREDICT_STATE, - context: PREDICT_CONTEXT, - encodeInput - }); -} diff --git a/examples/game/client/src/generated/freeze/schema.ts b/examples/game/client/src/generated/freeze/schema.ts deleted file mode 100644 index 5630875e..00000000 --- a/examples/game/client/src/generated/freeze/schema.ts +++ /dev/null @@ -1,163 +0,0 @@ -// @generated by `distributed game-client` from game surface `freeze` (schema v3, protocol v1). -// Replicated kinds: ids, typed fields, wire types, correction defaults. -// Do not edit: change the Rust definitions and regenerate. -import { fieldNumber, fieldString, type CorrectionPolicies, type EntityFields, type WireType } from '@hops-ops/distributed/game'; - -/** Replicated kind ids by name. */ -export const KIND = { - player: 1, - slime: 2, - gem: 3, - ritual: 4, -} as const; - -export type KindName = keyof typeof KIND; - -/** `player` (kind 1, priority 2). */ -export interface PlayerFields { - /** `quant [-20, 19.999847] / 18 bits` */ - readonly x: number; - /** `quant [-20, 19.999847] / 18 bits` */ - readonly y: number; - /** `quant [-16, 15.9921875] / 12 bits` */ - readonly vx: number; - /** `quant [-16, 15.9921875] / 12 bits` */ - readonly vy: number; - /** `u8` */ - readonly dash: number; - /** `u8` */ - readonly cd: number; - /** `short_str`, priority 0.2 */ - readonly name: string; -} - -/** True for a `player` entity. */ -export function isPlayer(entity: { readonly kind: number }): boolean { - return entity.kind === 1; -} - -/** Typed fields of a `player` entity (rendered or replica). */ -export function playerFields(entity: { readonly fields: EntityFields }): PlayerFields { - const f = entity.fields; - return { - x: fieldNumber(f, 'x'), - y: fieldNumber(f, 'y'), - vx: fieldNumber(f, 'vx'), - vy: fieldNumber(f, 'vy'), - dash: fieldNumber(f, 'dash'), - cd: fieldNumber(f, 'cd'), - name: fieldString(f, 'name'), - }; -} - -/** `slime` (kind 2, priority 1). */ -export interface SlimeFields { - /** `quant [-20, 20] / 14 bits` */ - readonly x: number; - /** `quant [-20, 20] / 14 bits` */ - readonly y: number; - /** `angle / 8 bits` */ - readonly yaw: number; -} - -/** True for a `slime` entity. */ -export function isSlime(entity: { readonly kind: number }): boolean { - return entity.kind === 2; -} - -/** Typed fields of a `slime` entity (rendered or replica). */ -export function slimeFields(entity: { readonly fields: EntityFields }): SlimeFields { - const f = entity.fields; - return { - x: fieldNumber(f, 'x'), - y: fieldNumber(f, 'y'), - yaw: fieldNumber(f, 'yaw'), - }; -} - -/** `gem` (kind 3, priority 1). */ -export interface GemFields { - /** `quant [-20, 20] / 14 bits` */ - readonly x: number; - /** `quant [-20, 20] / 14 bits` */ - readonly y: number; -} - -/** True for a `gem` entity. */ -export function isGem(entity: { readonly kind: number }): boolean { - return entity.kind === 3; -} - -/** Typed fields of a `gem` entity (rendered or replica). */ -export function gemFields(entity: { readonly fields: EntityFields }): GemFields { - const f = entity.fields; - return { - x: fieldNumber(f, 'x'), - y: fieldNumber(f, 'y'), - }; -} - -/** `ritual` (kind 4, priority 1). */ -export interface RitualFields { - /** `quant [-20, 20] / 14 bits` */ - readonly x: number; - /** `quant [-20, 20] / 14 bits` */ - readonly y: number; - /** `u8` */ - readonly stuck: number; -} - -/** True for a `ritual` entity. */ -export function isRitual(entity: { readonly kind: number }): boolean { - return entity.kind === 4; -} - -/** Typed fields of a `ritual` entity (rendered or replica). */ -export function ritualFields(entity: { readonly fields: EntityFields }): RitualFields { - const f = entity.fields; - return { - x: fieldNumber(f, 'x'), - y: fieldNumber(f, 'y'), - stuck: fieldNumber(f, 'stuck'), - }; -} - -/** Wire type of every replicated field, by kind and wire field name. */ -export const FIELD_TYPES = { - player: { - x: { type: 'quant', min: -20, max: 19.999847412109375, bits: 18 }, - y: { type: 'quant', min: -20, max: 19.999847412109375, bits: 18 }, - vx: { type: 'quant', min: -16, max: 15.9921875, bits: 12 }, - vy: { type: 'quant', min: -16, max: 15.9921875, bits: 12 }, - dash: { type: 'u8' }, - cd: { type: 'u8' }, - name: { type: 'short_str' }, - }, - slime: { - x: { type: 'quant', min: -20, max: 20, bits: 14 }, - y: { type: 'quant', min: -20, max: 20, bits: 14 }, - yaw: { type: 'angle', bits: 8 }, - }, - gem: { - x: { type: 'quant', min: -20, max: 20, bits: 14 }, - y: { type: 'quant', min: -20, max: 20, bits: 14 }, - }, - ritual: { - x: { type: 'quant', min: -20, max: 20, bits: 14 }, - y: { type: 'quant', min: -20, max: 20, bits: 14 }, - stuck: { type: 'u8' }, - }, -} as const satisfies Record>; - -/** Client correction defaults (`#[correct(..)]`), by kind and wire field name. */ -export const CORRECTIONS: CorrectionPolicies = { - player: { - x: { mode: 'ease', durationMs: 150 }, - y: { mode: 'ease', durationMs: 150 }, - vx: { mode: 'snap' }, - vy: { mode: 'snap' }, - dash: { mode: 'snap' }, - cd: { mode: 'snap' }, - name: { mode: 'replace' }, - }, -}; diff --git a/examples/game/client/src/generated/freeze/surface.json b/examples/game/client/src/generated/freeze/surface.json deleted file mode 100644 index 736bd60f..00000000 --- a/examples/game/client/src/generated/freeze/surface.json +++ /dev/null @@ -1,980 +0,0 @@ -{ - "codecs": [ - "Pack" - ], - "constants": [ - { - "name": "arena_floor", - "type": { - "name": "ArenaFloor", - "type": "record" - }, - "value": { - "dash_cooldown": 45, - "half": 20.0, - "pillars": [ - { - "r": 2.5, - "x": 0.0, - "y": 0.0 - }, - { - "r": 1.5, - "x": -9.0, - "y": -9.0 - }, - { - "r": 1.5, - "x": 9.0, - "y": -9.0 - }, - { - "r": 1.5, - "x": -9.0, - "y": 9.0 - }, - { - "r": 1.5, - "x": 9.0, - "y": 9.0 - }, - { - "r": 1.0, - "x": 0.0, - "y": -14.0 - }, - { - "r": 1.0, - "x": 0.0, - "y": 14.0 - }, - { - "r": 1.0, - "x": -14.0, - "y": 0.0 - }, - { - "r": 1.0, - "x": 14.0, - "y": 0.0 - } - ], - "player_radius": 0.5 - } - }, - { - "name": "freeze_world", - "type": { - "name": "FreezeWorld", - "type": "record" - }, - "value": { - "crystals": [ - [ - { - "x": -17.0, - "y": -17.0 - }, - { - "x": -11.0, - "y": -17.5 - }, - { - "x": 17.0, - "y": -16.0 - }, - { - "x": 17.5, - "y": 11.0 - }, - { - "x": -17.5, - "y": 15.0 - }, - { - "x": -8.0, - "y": 17.0 - }, - { - "x": 10.0, - "y": 17.5 - } - ], - [ - { - "x": 17.0, - "y": -17.0 - }, - { - "x": 11.0, - "y": -17.5 - }, - { - "x": -17.0, - "y": -16.0 - }, - { - "x": -17.5, - "y": 11.0 - }, - { - "x": 17.5, - "y": 15.0 - }, - { - "x": 8.0, - "y": 17.0 - }, - { - "x": -10.0, - "y": 17.5 - } - ], - [ - { - "x": -17.0, - "y": -17.0 - }, - { - "x": -17.5, - "y": -11.0 - }, - { - "x": -16.0, - "y": 17.0 - }, - { - "x": 11.0, - "y": 17.5 - }, - { - "x": 15.0, - "y": -17.5 - }, - { - "x": 17.0, - "y": -8.0 - }, - { - "x": 17.5, - "y": 10.0 - } - ] - ], - "ember": { - "gate": false, - "id": "freeze-ember", - "pools": [ - { - "r": 2.5, - "x": -5.0, - "y": -5.0 - }, - { - "r": 3.0, - "x": 6.0, - "y": 5.0 - }, - { - "r": 2.0, - "x": -4.0, - "y": 12.0 - } - ], - "portals": [ - { - "title": "Town Hub (safe room)", - "to": "freeze-hub", - "x": -16.5, - "y": 5.0 - } - ], - "ritual": { - "x": 0.0, - "y": -10.0 - }, - "title": "Ember Fields" - }, - "hub": { - "gate": true, - "id": "freeze-hub", - "pools": [], - "portals": [ - { - "title": "Whisper Meadow", - "to": "freeze-meadow", - "x": -16.5, - "y": 5.0 - }, - { - "title": "Ember Fields", - "to": "freeze-ember", - "x": 16.5, - "y": 5.0 - } - ], - "ritual": { - "x": 0.0, - "y": -10.0 - }, - "title": "Town Hub (safe room)" - }, - "meadow": { - "gate": false, - "id": "freeze-meadow", - "pools": [ - { - "r": 2.200000047683716, - "x": -6.0, - "y": 6.0 - }, - { - "r": 2.5, - "x": 6.0, - "y": -5.0 - } - ], - "portals": [ - { - "title": "Town Hub (safe room)", - "to": "freeze-hub", - "x": 16.5, - "y": 5.0 - } - ], - "ritual": { - "x": 0.0, - "y": -10.0 - }, - "title": "Whisper Meadow" - } - } - } - ], - "events": [ - { - "id": 1, - "name": "hello", - "payload": { - "fields": [ - { - "name": "entity", - "type": { - "type": "u32" - } - }, - { - "name": "zone", - "type": { - "type": "short_str" - } - }, - { - "name": "title", - "type": { - "type": "short_str" - } - }, - { - "name": "gate", - "type": { - "type": "bool" - } - }, - { - "name": "host", - "type": { - "type": "short_str" - } - }, - { - "name": "half", - "type": { - "type": "f32" - } - }, - { - "name": "content_version", - "type": { - "type": "short_str" - } - } - ], - "kind": "fields" - } - }, - { - "id": 2, - "name": "gem", - "payload": { - "fields": [ - { - "name": "by", - "type": { - "type": "short_str" - } - }, - { - "name": "value", - "type": { - "type": "u32" - } - }, - { - "name": "stamp", - "type": { - "name": "Stamp", - "type": "record" - } - } - ], - "kind": "fields" - } - }, - { - "id": 3, - "name": "burned", - "payload": { - "fields": [ - { - "name": "name", - "type": { - "type": "short_str" - } - }, - { - "name": "stamp", - "type": { - "name": "Stamp", - "type": "record" - } - } - ], - "kind": "fields" - } - }, - { - "id": 4, - "name": "content", - "payload": { - "fields": [ - { - "name": "from", - "type": { - "type": "short_str" - } - }, - { - "name": "to", - "type": { - "type": "short_str" - } - }, - { - "name": "name", - "type": { - "type": "short_str" - } - }, - { - "name": "stamp", - "type": { - "name": "Stamp", - "type": "record" - } - } - ], - "kind": "fields" - } - } - ], - "input": { - "fields": [ - { - "name": "mx", - "range": [ - -127.0, - 127.0 - ], - "type": { - "type": "i8" - } - }, - { - "name": "my", - "range": [ - -127.0, - 127.0 - ], - "type": { - "type": "i8" - } - }, - { - "buttons": [ - "dash" - ], - "name": "buttons", - "type": { - "type": "u8" - } - } - ], - "name": "MoveInput" - }, - "kinds": [ - { - "fields": [ - { - "correction": { - "durationMs": 150, - "mode": "ease" - }, - "name": "x", - "priority": 1.0, - "type": { - "bits": 18, - "max": 19.999847, - "min": -20.0, - "type": "quant" - } - }, - { - "correction": { - "durationMs": 150, - "mode": "ease" - }, - "name": "y", - "priority": 1.0, - "type": { - "bits": 18, - "max": 19.999847, - "min": -20.0, - "type": "quant" - } - }, - { - "correction": { - "mode": "snap" - }, - "name": "vx", - "priority": 1.0, - "type": { - "bits": 12, - "max": 15.9921875, - "min": -16.0, - "type": "quant" - } - }, - { - "correction": { - "mode": "snap" - }, - "name": "vy", - "priority": 1.0, - "type": { - "bits": 12, - "max": 15.9921875, - "min": -16.0, - "type": "quant" - } - }, - { - "correction": { - "mode": "snap" - }, - "name": "dash", - "priority": 1.0, - "type": { - "type": "u8" - } - }, - { - "correction": { - "mode": "snap" - }, - "name": "cd", - "priority": 1.0, - "type": { - "type": "u8" - } - }, - { - "correction": { - "mode": "replace" - }, - "name": "name", - "priority": 0.2, - "type": { - "type": "short_str" - } - } - ], - "id": 1, - "name": "player", - "priority": 2.0 - }, - { - "fields": [ - { - "correction": null, - "name": "x", - "priority": 1.0, - "type": { - "bits": 14, - "max": 20.0, - "min": -20.0, - "type": "quant" - } - }, - { - "correction": null, - "name": "y", - "priority": 1.0, - "type": { - "bits": 14, - "max": 20.0, - "min": -20.0, - "type": "quant" - } - }, - { - "correction": null, - "name": "yaw", - "priority": 1.0, - "type": { - "bits": 8, - "type": "angle" - } - } - ], - "id": 2, - "name": "slime", - "priority": 1.0 - }, - { - "fields": [ - { - "correction": null, - "name": "x", - "priority": 1.0, - "type": { - "bits": 14, - "max": 20.0, - "min": -20.0, - "type": "quant" - } - }, - { - "correction": null, - "name": "y", - "priority": 1.0, - "type": { - "bits": 14, - "max": 20.0, - "min": -20.0, - "type": "quant" - } - } - ], - "id": 3, - "name": "gem", - "priority": 1.0 - }, - { - "fields": [ - { - "correction": null, - "name": "x", - "priority": 1.0, - "type": { - "bits": 14, - "max": 20.0, - "min": -20.0, - "type": "quant" - } - }, - { - "correction": null, - "name": "y", - "priority": 1.0, - "type": { - "bits": 14, - "max": 20.0, - "min": -20.0, - "type": "quant" - } - }, - { - "correction": null, - "name": "stuck", - "priority": 1.0, - "type": { - "type": "u8" - } - } - ], - "id": 4, - "name": "ritual", - "priority": 1.0 - } - ], - "name": "freeze", - "predict": { - "abi": 1, - "export": "arena_step", - "kind": "player", - "package": "game_sim", - "state": "MoveState" - }, - "protocolVersion": 1, - "records": [ - { - "fields": [ - { - "name": "half", - "type": { - "type": "f32" - } - }, - { - "name": "player_radius", - "type": { - "type": "f32" - } - }, - { - "name": "dash_cooldown", - "type": { - "type": "u8" - } - }, - { - "name": "pillars", - "type": { - "item": { - "name": "Pillar", - "type": "record" - }, - "type": "list" - } - } - ], - "name": "ArenaFloor" - }, - { - "fields": [ - { - "name": "hub", - "type": { - "name": "ZoneLayout", - "type": "record" - } - }, - { - "name": "meadow", - "type": { - "name": "ZoneLayout", - "type": "record" - } - }, - { - "name": "ember", - "type": { - "name": "ZoneLayout", - "type": "record" - } - }, - { - "name": "crystals", - "type": { - "item": { - "item": { - "name": "Spot", - "type": "record" - }, - "type": "list" - }, - "type": "list" - } - } - ], - "name": "FreezeWorld" - }, - { - "fields": [ - { - "name": "x", - "type": { - "type": "f32" - } - }, - { - "name": "y", - "type": { - "type": "f32" - } - }, - { - "name": "vx", - "type": { - "type": "f32" - } - }, - { - "name": "vy", - "type": { - "type": "f32" - } - }, - { - "name": "dash", - "type": { - "type": "u8" - } - }, - { - "name": "cd", - "type": { - "type": "u8" - } - } - ], - "name": "MoveState" - }, - { - "fields": [ - { - "name": "version", - "type": { - "type": "short_str" - } - }, - { - "name": "name", - "type": { - "type": "short_str" - } - }, - { - "name": "notes", - "type": { - "item": { - "type": "short_str" - }, - "type": "list" - } - }, - { - "name": "hazard", - "type": { - "type": "short_str" - } - }, - { - "name": "slime_speed", - "type": { - "type": "f32" - } - }, - { - "name": "slime_color", - "type": { - "type": "short_str" - } - }, - { - "name": "slime_scale", - "type": { - "type": "f32" - } - }, - { - "name": "crystal_color", - "type": { - "type": "short_str" - } - }, - { - "name": "crystal_layout", - "type": { - "type": "u8" - } - }, - { - "name": "gem_value", - "type": { - "type": "u32" - } - } - ], - "name": "Pack" - }, - { - "fields": [ - { - "name": "x", - "type": { - "type": "f32" - } - }, - { - "name": "y", - "type": { - "type": "f32" - } - }, - { - "name": "r", - "type": { - "type": "f32" - } - } - ], - "name": "Pillar" - }, - { - "fields": [ - { - "name": "x", - "type": { - "type": "f32" - } - }, - { - "name": "y", - "type": { - "type": "f32" - } - }, - { - "name": "r", - "type": { - "type": "f32" - } - } - ], - "name": "Pool" - }, - { - "fields": [ - { - "name": "x", - "type": { - "type": "f32" - } - }, - { - "name": "y", - "type": { - "type": "f32" - } - }, - { - "name": "to", - "type": { - "type": "short_str" - } - }, - { - "name": "title", - "type": { - "type": "short_str" - } - } - ], - "name": "Portal" - }, - { - "fields": [ - { - "name": "x", - "type": { - "type": "f32" - } - }, - { - "name": "y", - "type": { - "type": "f32" - } - } - ], - "name": "Spot" - }, - { - "fields": [ - { - "name": "zone", - "type": { - "type": "short_str" - } - }, - { - "name": "tick", - "type": { - "type": "u64" - } - }, - { - "name": "content_version", - "type": { - "type": "short_str" - } - } - ], - "name": "Stamp" - }, - { - "fields": [ - { - "name": "id", - "type": { - "type": "short_str" - } - }, - { - "name": "title", - "type": { - "type": "short_str" - } - }, - { - "name": "gate", - "type": { - "type": "bool" - } - }, - { - "name": "portals", - "type": { - "item": { - "name": "Portal", - "type": "record" - }, - "type": "list" - } - }, - { - "name": "pools", - "type": { - "item": { - "name": "Pool", - "type": "record" - }, - "type": "list" - } - }, - { - "name": "ritual", - "type": { - "name": "Spot", - "type": "record" - } - } - ], - "name": "ZoneLayout" - } - ], - "schemaVersion": 3 -} diff --git a/examples/game/client/tests/freeze-headless.test.mjs b/examples/game/client/tests/freeze-headless.test.mjs deleted file mode 100644 index 457690c5..00000000 --- a/examples/game/client/tests/freeze-headless.test.mjs +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Headless run of The Freeze against the real `game-demos freeze` binary - * (primary process + the second host it spawns). Two players in Ember - * Fields (second host): Ana patches content live, Bo is a pinned old - * client. - * - * 1. Rolling rollout v1 → v2 across both hosts: Ana patches, Bo keeps - * playing v1 in the field and reloads only when it walks into the hub - * gate (Ana walks in without a reload). - * 2. Hard rollout v2 → v3: inputs during the freeze are dropped and - * counted, every client reloads at once. - * 3. A stuck zone (ritual in Ember Fields) aborts the next rollout: - * everyone resumes on v3 with "update aborted". - */ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { createFreezeClient, FreezeAdmin, ZONES } from '../src/freeze/client.ts'; -import { DevApi } from '../src/shared/dev-api.ts'; -import { startCelld } from './helpers/celld.mjs'; -import { memoryStorage, sleep, startDemo, until } from './helpers/demo-server.mjs'; - -const ADMIN = 'freeze-test-admin'; - -async function player(base, name, pinned) { - const api = new DevApi({ base, name: () => name, storage: memoryStorage() }); - return createFreezeClient({ api, base, pinned: () => pinned }); -} - -/** Steer with command frames toward (x, y) until `done()`. */ -async function walk(p, [x, y], done, timeoutMs = 15_000) { - const deadline = Date.now() + timeoutMs; - while (!done()) { - assert.ok(Date.now() < deadline, `walk to ${x},${y} timed out`); - const fields = p.client.predictor?.active ? p.client.predictor.predicted() : undefined; - if (fields !== undefined) { - const at = { x: Number(fields.x), y: Number(fields.y) }; - const dx = x - at.x; - const dy = y - at.y; - const d = Math.hypot(dx, dy); - const k = d > 0.01 ? 127 / d : 0; - p.sendInput({ mx: Math.round(dx * k), my: Math.round(dy * k), buttons: 0 }); - } - await sleep(1000 / 30); - } -} - -test('world barrier across two hosts: rolling + safe room, hard reload, aborted rollout', { timeout: 600_000 }, async (t) => { - // Both Freeze hosts settle their facts in celld cells. - const celld = await startCelld(); - let server; - try { - server = await startDemo('freeze', { env: { ...celld.env, DG_DEMO_ADMIN_SECRET: ADMIN, DG_FREEZE_STAGE_MS: '700' } }); - } catch (error) { - await celld.stop(); - throw error; - } - const base = server.httpUrl; - const admin = new FreezeAdmin(base, ADMIN); - const players = []; - try { - const ana = await player(base, 'Ana', false); - const bo = await player(base, 'Bo', true); - players.push(ana, bo); - assert.equal(ana.pack().version, 'v1'); - assert.equal(ana.pack().hazard, 'lava'); - for (const p of [ana, bo]) { - const hello = p.nextHello(); - await p.client.connect(ZONES.ember); - const h = await hello; - assert.equal(h.host, 'beta', 'Ember Fields runs on the second process'); - assert.equal(h.contentVersion, 'v1'); - await until(() => p.client.predictor.active, 5_000, 'bound'); - } - assert.match(bo.client.authority.connection.url, /\/zones\/freeze-ember$/); - assert.notEqual(new URL(bo.client.authority.connection.url).port, new URL(base).port, 'redirected to beta'); - await assert.rejects(new FreezeAdmin(base, 'wrong').rollout('hard'), /401/); - - // ---- 1. Rolling (safe room) v1 → v2 ---- - const rolling = await admin.rollout('rolling'); - assert.equal(rolling.ok, true, JSON.stringify(rolling)); - assert.equal(rolling.version, 'v2'); - await until(() => ana.lastResume()?.version === 'v2' && bo.lastResume()?.version === 'v2', 5_000, 'resume v2'); - assert.ok(ana.lastResume().notes.some((n) => n.includes('Lava is now ice'))); - await until(() => ana.client.contentVersion === 'v2' && ana.pack().hazard === 'ice', 5_000, 'ana patched'); - assert.equal(ana.lastResume().patched, true); - assert.equal(bo.lastResume().patched, false); - assert.equal(bo.client.contentVersion, 'v1', 'pinned client keeps its build'); - assert.equal(bo.pack().hazard, 'lava'); - assert.ok(bo.stale()); - assert.deepEqual([ana.reloads.length, bo.reloads.length], [0, 0], 'nobody reloaded in the field'); - // The stale client keeps playing in the field zone: inputs are acked. - const ack = bo.client.predictor.lastAck; - for (let i = 0; i < 10; i += 1) { - bo.sendInput({ mx: 0, my: 40, buttons: 0 }); - await sleep(1000 / 30); - } - await until(() => bo.client.predictor.lastAck >= ack + 10, 5_000, 'stale inputs acked'); - assert.equal(bo.client.authority.zoneId, ZONES.ember); - - // Both walk west into the hub gate (cross-host handoff beta → alpha). - const portal = [-16.5, 5]; - await Promise.all( - [ana, bo].map((p) => walk(p, portal, () => p.hello()?.zone === ZONES.hub && p.client.authority?.connection.state === 'open')) - ); - await until(() => bo.reloads.length === 1 && bo.client.contentVersion === 'v2', 10_000, 'bo reloaded at the gate'); - assert.deepEqual(bo.reloads, [{ zone: ZONES.hub, version: 'v2' }], 'reload happened at the hub'); - await until(() => bo.client.authority.connection.state === 'open' && !bo.stale(), 10_000, 'bo back'); - assert.equal(bo.pack().hazard, 'ice'); - assert.equal(ana.reloads.length, 0, 'an up-to-date client enters the gate without reloading'); - assert.equal(bo.hello().host, 'alpha'); - - // ---- 2. Hard v2 → v3: inputs during the freeze are dropped ---- - const hard = admin.rollout('hard'); - await until(() => ana.frozen() !== undefined && bo.frozen() !== undefined, 5_000, 'frozen'); - assert.equal(ana.frozen().version, 'v3'); - assert.equal(ana.client.authority.connection.canSendInput(), false); - for (let i = 0; i < 5; i += 1) { - assert.equal(ana.sendInput({ mx: 127, my: 0, buttons: 0 }), undefined); - await sleep(20); - } - assert.ok(ana.droppedWhileFrozen() >= 5); - const hardResult = await hard; - assert.equal(hardResult.ok, true, JSON.stringify(hardResult)); - assert.equal(hardResult.version, 'v3'); - await until( - () => [ana, bo].every((p) => p.client.contentVersion === 'v3' && p.client.authority?.connection.state === 'open'), - 10_000, - 'everyone reloaded to v3' - ); - assert.deepEqual(ana.reloads, [{ zone: ZONES.hub, version: 'v3' }]); - assert.deepEqual(bo.reloads.at(-1), { zone: ZONES.hub, version: 'v3' }); - assert.equal(ana.pack().name, 'Bloom Update'); - - // Facts on both hosts carry the version they were produced under - // (listed once they settled in their celld cells). - const settledFacts = (world) => world.hosts.flatMap((h) => (h.facts ?? []).map((f) => ({ host: h.host, ...f }))); - let world = await admin.world(); - for (const deadline = Date.now() + 10_000; Date.now() < deadline; ) { - const applied = settledFacts(world).filter((f) => f.command === 'freeze.content_applied' && f.stamp.contentVersion === 'v3'); - if (applied.length >= 3) break; - await sleep(100); - world = await admin.world(); - } - assert.equal(world.version, 'v3'); - const facts = settledFacts(world); - for (const [host, zone] of [ - ['alpha', ZONES.hub], - ['beta', ZONES.ember] - ]) { - for (const version of ['v2', 'v3']) { - assert.ok( - facts.some( - (f) => f.host === host && f.command === 'freeze.content_applied' && f.stamp.zone === zone && f.stamp.contentVersion === version - ), - `${host}/${zone} ${version} fact: ${JSON.stringify(facts)}` - ); - } - } - assert.ok(world.hosts.every((h) => h.version === 'v3' && h.zones.every((z) => z.state === 'running'))); - assert.ok(ana.log.some((e) => e.kind === 'content' && e.stamp?.contentVersion === 'v3'), 'stamped content event'); - - // ---- 3. Stuck zone → aborted rollout, everyone stays on v3 ---- - const stuck = await admin.setStuck(ZONES.ember, true); - assert.equal(stuck.host, 'beta'); - const aborted = await admin.rollout('hard'); - assert.equal(aborted.ok, false); - assert.equal(aborted.error, 'not_acked'); - assert.deepEqual(aborted.missing, [`beta/${ZONES.ember}`]); - await until(() => ana.lastResume()?.aborted === true && bo.lastResume()?.aborted === true, 5_000, 'abort notice'); - assert.equal(ana.lastResume().version, 'v3'); - assert.equal(ana.client.contentVersion, 'v3'); - assert.equal(ana.reloads.length, 1, 'no reload on abort'); - const after = await admin.world(); - assert.ok(after.hosts.every((h) => h.version === 'v3')); - await admin.setStuck(ZONES.ember, false); - t.diagnostic(`dropped while frozen: ana ${ana.droppedWhileFrozen()} · rollouts ${after.rollouts.map((r) => `${r.version}${r.ok ? '' : ' (aborted)'}`).join(', ')}`); - } finally { - for (const p of players) p.client.close(); - await server.stop(); - await celld.stop(); - } -}); diff --git a/examples/game/server/src/freeze/content.rs b/examples/game/server/src/freeze/content.rs deleted file mode 100644 index efb014a8..00000000 --- a/examples/game/server/src/freeze/content.rs +++ /dev/null @@ -1,443 +0,0 @@ -//! Data-driven content of **The Freeze**: one pack per content version. -//! -//! Version `v{n}` uses pack `(n - 1) % 3`, so the admin can keep rolling -//! out "new" versions. The server derives its rules (hazard, slime speed, -//! gem value) from the pack of the zone's content version; clients fetch -//! the same pack (`GET /dev/freeze/content/{version}`, the demo's stand-in -//! for a content CDN) and derive colors, props and labels from it. A client -//! pinned to an old version keeps rendering its old pack. -//! -//! Everything here is defined once, in Rust. A [`Pack`] is a -//! `#[derive(GameRecord)]`: the CDN serves its payload-codec encoding and -//! the generated client decodes it (`decodePack`, from -//! `GameSurface::record::()`). The version-independent layout -//! (zones, portals, pools, crystal spots) is [`freeze_world`], the -//! generated `FREEZE_WORLD` constant (`GameSurface::constant`). Packs are -//! *served* rather than compiled into the client because the point of the -//! demo is a client receiving content it was not built with. - -use distributed_game::codec::{Wire, WireType}; -use distributed_game::protocol::{DecodeError, Reader, Writer}; -use distributed_game::GameRecord; - -/// What standing in a pool does (on the wire: `"lava"` / `"ice"`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Hazard { - /// Burns: the player is sent back to a spawn point. - Lava, - /// Safe to cross. - Ice, -} - -impl Hazard { - /// Wire name. - pub fn name(self) -> &'static str { - match self { - Self::Lava => "lava", - Self::Ice => "ice", - } - } -} - -impl Wire for Hazard { - fn wire_type() -> WireType { - WireType::ShortStr - } - fn encode(&self, w: &mut Writer) { - w.short_string(self.name()); - } - fn decode(r: &mut Reader<'_>) -> Result { - match r.short_string()?.as_str() { - "lava" => Ok(Self::Lava), - "ice" => Ok(Self::Ice), - _ => Err(DecodeError::Invalid("unknown hazard")), - } - } -} - -/// One content pack (served encoded; the client's `Pack` is generated). -#[derive(Debug, Clone, PartialEq, GameRecord)] -pub struct Pack { - /// `v{n}`. - pub version: String, - /// Display name of the update. - pub name: String, - /// "Here's what changed" bullet points (relative to the previous pack). - pub notes: Vec, - /// Pool behaviour. - pub hazard: Hazard, - /// Slime wander speed (m/s). - pub slime_speed: f32, - /// Slime color (hex). - pub slime_color: String, - /// Slime scale. - pub slime_scale: f32, - /// Crystal prop color (hex). - pub crystal_color: String, - /// Index into [`FreezeWorld::crystals`] (mirrored per pack). - pub crystal_layout: u8, - /// Value of a collected gem. - pub gem_value: u32, -} - -/// Number of distinct packs. -pub const PACKS: u32 = 3; - -/// Version number of `version` (`"v7"` → 7). -pub fn version_number(version: &str) -> Option { - version - .strip_prefix('v')? - .parse() - .ok() - .filter(|n: &u32| *n >= 1) -} - -/// `"v{n}"`. -pub fn version_name(n: u32) -> String { - format!("v{n}") -} - -/// The pack of `version` (`None` for a malformed version). -pub fn pack(version: &str) -> Option { - let n = version_number(version)?; - let (name, notes, hazard, speed, color, scale, crystal, layout, gem) = match (n - 1) % PACKS { - 0 => ( - "Ember Season", - vec![ - "The thaw is over: the pools are molten lava again (they burn).", - "Slimes are back to a calm 2.0 m/s and green.", - "Crystals glow orange; gems are worth 1.", - ], - Hazard::Lava, - 2.0, - "#7ed957", - 1.0, - "#ff9d3c", - 0, - 1, - ), - 1 => ( - "Frostfall Patch", - vec![ - "Lava is now ice: the pools are safe to cross.", - "Slimes are faster (2.0 → 3.5 m/s), bigger and blue.", - "Crystals moved and turned cyan; gems are worth 2.", - ], - Hazard::Ice, - 3.5, - "#5ab8ff", - 1.35, - "#7ff3ff", - 1, - 2, - ), - _ => ( - "Bloom Update", - vec![ - "Spring thaw: the ice melted back into lava (it burns again).", - "Slimes are slow (1.2 m/s), tiny and pink.", - "Crystals bloomed magenta; gems are worth 3.", - ], - Hazard::Lava, - 1.2, - "#ff7ad9", - 0.7, - "#d77bff", - 2, - 3, - ), - }; - Some(Pack { - version: version_name(n), - name: name.into(), - notes: notes.into_iter().map(String::from).collect(), - hazard, - slime_speed: speed, - slime_color: color.into(), - slime_scale: scale, - crystal_color: crystal.into(), - crystal_layout: layout, - gem_value: gem, - }) -} - -impl Pack { - /// Notes for the barrier's `Resume{notes}`: the name, then one line per - /// change. - pub fn resume_notes(&self) -> String { - let mut out = format!("{} ({})", self.name, self.version); - for note in &self.notes { - out.push('\n'); - out.push_str(note); - } - out - } - - /// What the content CDN serves: the payload-codec encoding. - pub fn to_bytes(&self) -> Vec { - distributed_game::codec::to_bytes(self) - } -} - -/// Kind of zone. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ZoneKind { - /// Town hub: the safe room (gate). - Hub, - /// Field zone on the primary host. - Meadow, - /// Field zone on the second host. - Ember, -} - -/// Zone ids of the world. -pub const HUB: &str = "freeze-hub"; -/// Meadow zone id. -pub const MEADOW: &str = "freeze-meadow"; -/// Ember Fields zone id. -pub const EMBER: &str = "freeze-ember"; -/// Every zone (activated at startup so the barrier reaches them all). -pub const ZONES: [&str; 3] = [HUB, MEADOW, EMBER]; - -impl ZoneKind { - /// Kind of a zone id. - pub fn of(zone_id: &str) -> Option { - match zone_id { - HUB => Some(Self::Hub), - MEADOW => Some(Self::Meadow), - EMBER => Some(Self::Ember), - _ => None, - } - } - - /// Display title. - pub fn title(self) -> &'static str { - match self { - Self::Hub => "Town Hub (safe room)", - Self::Meadow => "Whisper Meadow", - Self::Ember => "Ember Fields", - } - } - - /// Portals `(x, y, target zone)`. - pub fn portals(self) -> &'static [(f32, f32, &'static str)] { - match self { - Self::Hub => &[(-16.5, 5.0, MEADOW), (16.5, 5.0, EMBER)], - Self::Meadow => &[(16.5, 5.0, HUB)], - Self::Ember => &[(-16.5, 5.0, HUB)], - } - } - - /// Pools `(x, y, radius)`. - pub fn pools(self) -> &'static [(f32, f32, f32)] { - match self { - Self::Hub => &[], - Self::Meadow => &[(-6.0, 6.0, 2.2), (6.0, -5.0, 2.5)], - Self::Ember => &[(-5.0, -5.0, 2.5), (6.0, 5.0, 3.0), (-4.0, 12.0, 2.0)], - } - } - - /// Gem spots. - pub fn gem_spots(self) -> &'static [(f32, f32)] { - match self { - Self::Hub => &[], - Self::Meadow | Self::Ember => &[ - (-12.0, -12.0), - (12.0, -12.0), - (12.0, 12.0), - (-12.0, 12.0), - (-3.0, -16.0), - (3.0, 16.0), - ], - } - } - - /// Wandering slimes. - pub fn slimes(self) -> u32 { - match self { - Self::Hub => 0, - Self::Meadow => 6, - Self::Ember => 8, - } - } - - /// Where a "ritual" can hold the zone (the stuck-zone toggle). - pub fn ritual(self) -> (f32, f32) { - (0.0, -10.0) - } -} - -/// Fresh-join spawn points (clear of pools and pillars). -pub const SPAWNS: [(f32, f32); 4] = [(0.0, -6.0), (0.0, 6.0), (-6.0, 0.0), (6.0, 0.0)]; - -/// Crystal props for a layout id (decor, no collision). -pub fn crystals(layout: u8) -> Vec<(f32, f32)> { - let base = [ - (-17.0, -17.0), - (-11.0, -17.5), - (17.0, -16.0), - (17.5, 11.0), - (-17.5, 15.0), - (-8.0, 17.0), - (10.0, 17.5), - ]; - base.iter() - .map(|&(x, y)| match layout { - 0 => (x, y), - 1 => (-x, y), - _ => (y, x), - }) - .collect() -} - -/// A point of a zone (m). -#[derive(Debug, Clone, Copy, PartialEq, GameRecord)] -pub struct Spot { - /// x. - pub x: f32, - /// y. - pub y: f32, -} - -/// A portal to another zone. -#[derive(Debug, Clone, PartialEq, GameRecord)] -pub struct Portal { - /// x. - pub x: f32, - /// y. - pub y: f32, - /// Target zone id. - pub to: String, - /// Target zone title. - pub title: String, -} - -/// A pool (lava or ice, by pack). -#[derive(Debug, Clone, Copy, PartialEq, GameRecord)] -pub struct Pool { - /// x. - pub x: f32, - /// y. - pub y: f32, - /// Radius. - pub r: f32, -} - -/// The layout of one zone. -#[derive(Debug, Clone, PartialEq, GameRecord)] -pub struct ZoneLayout { - /// Zone id. - pub id: String, - /// Display title. - pub title: String, - /// The safe-room gate (the hub). - pub gate: bool, - /// Portals. - pub portals: Vec, - /// Pools. - pub pools: Vec, - /// Where a ritual holds the zone. - pub ritual: Spot, -} - -/// The version-independent world (generated `FREEZE_WORLD`). -#[derive(Debug, Clone, PartialEq, GameRecord)] -pub struct FreezeWorld { - /// Town hub. - pub hub: ZoneLayout, - /// Whisper Meadow. - pub meadow: ZoneLayout, - /// Ember Fields. - pub ember: ZoneLayout, - /// Crystal spots per layout id ([`Pack::crystal_layout`]). - pub crystals: Vec>, -} - -/// The `FREEZE_WORLD` constant. -pub fn freeze_world() -> FreezeWorld { - let zone = |kind: ZoneKind, id: &str| ZoneLayout { - id: id.into(), - title: kind.title().into(), - gate: kind == ZoneKind::Hub, - portals: kind - .portals() - .iter() - .map(|&(x, y, to)| Portal { - x, - y, - to: to.into(), - title: ZoneKind::of(to).map_or("", ZoneKind::title).into(), - }) - .collect(), - pools: kind - .pools() - .iter() - .map(|&(x, y, r)| Pool { x, y, r }) - .collect(), - ritual: Spot { - x: kind.ritual().0, - y: kind.ritual().1, - }, - }; - FreezeWorld { - hub: zone(ZoneKind::Hub, HUB), - meadow: zone(ZoneKind::Meadow, MEADOW), - ember: zone(ZoneKind::Ember, EMBER), - crystals: (0..PACKS as u8) - .map(|layout| { - crystals(layout) - .into_iter() - .map(|(x, y)| Spot { x, y }) - .collect() - }) - .collect(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn versions_cycle_through_packs() { - assert_eq!(version_number("v12"), Some(12)); - assert_eq!(version_number("v0"), None); - assert_eq!(version_number("x1"), None); - assert_eq!(pack("v1").unwrap().hazard, Hazard::Lava); - assert_eq!(pack("v2").unwrap().hazard, Hazard::Ice); - assert_eq!(pack("v4").unwrap().name, pack("v1").unwrap().name); - assert_eq!(pack("v4").unwrap().version, "v4"); - let served: Pack = - distributed_game::codec::from_bytes(&pack("v2").unwrap().to_bytes()).unwrap(); - assert_eq!(served, pack("v2").unwrap()); - let world = freeze_world(); - assert_eq!(world.ember.portals[0].to, HUB); - assert_eq!(world.ember.portals[0].title, ZoneKind::Hub.title()); - assert_eq!(world.crystals.len(), PACKS as usize); - assert!(pack("v2") - .unwrap() - .resume_notes() - .starts_with("Frostfall Patch (v2)\n")); - } - - #[test] - fn layout_is_clear_of_pillars() { - use game_sim::arena::PILLARS; - for kind in [ZoneKind::Hub, ZoneKind::Meadow, ZoneKind::Ember] { - let points = kind - .portals() - .iter() - .map(|p| (p.0, p.1)) - .chain(kind.gem_spots().iter().copied()) - .chain(SPAWNS); - for (x, y) in points { - for (px, py, pr) in PILLARS { - assert!(((x - px).powi(2) + (y - py).powi(2)).sqrt() > pr + 1.0); - } - for (qx, qy, qr) in kind.pools() { - assert!(((x - qx).powi(2) + (y - qy).powi(2)).sqrt() > qr + 1.0); - } - } - } - } -} diff --git a/examples/game/server/src/freeze/mod.rs b/examples/game/server/src/freeze/mod.rs deleted file mode 100644 index 97a028bb..00000000 --- a/examples/game/server/src/freeze/mod.rs +++ /dev/null @@ -1,673 +0,0 @@ -//! **The Freeze** — a world-version barrier as an in-world story beat. -//! -//! Three zones on two server processes: the **Town Hub** (a safe room, -//! [`Zone::is_gate`]) and **Whisper Meadow** on the primary host, **Ember -//! Fields** on the second host. Their rules come from the content pack of -//! the zone's content version ([`content`]): lava pools that burn (or ice -//! that does not), slime speed, gem value. An administrator rolls out the -//! next version with [`world`]'s `/dev/freeze/rollout` (admin secret): -//! `WorldCoordinator::rollout` freezes every zone on both hosts (clients -//! show "SYSTEM: an administrator has paused the world…", inputs are -//! dropped and counted), the update is applied, every zone resumes on the -//! new version with the change notes. -//! -//! - **Hard**: every client is told `Kick(RELOAD)`, reloads the pack and -//! reconnects with its token. -//! - **Rolling + SafeRoom** (`StalePolicy::SafeRoom { roam: false }`): -//! clients that patch content live keep going; an old client (the page's -//! "simulate old client") keeps playing its old pack in a field zone and is -//! only reloaded when it walks through a portal into the hub gate. -//! - **Abort**: a zone with a ritual in progress refuses -//! [`Zone::freeze_ready`]; the barrier times out (`NotAcked`) and every -//! zone resumes on the old version ("update aborted"). -//! -//! Movement is the Arena's step ([`movement::arena_step`]), -//! which the client predicts with, compiled to WebAssembly. -//! -//! Entities: 1 `player` (the Arena's [`PlayerView`]), 2 `slime` (x y yaw), -//! 3 `gem` (x y), 4 `ritual` (global: x y stuck). Events: [`FreezeEvent`]; -//! gameplay events carry `ctx.stamp()` (zone, tick, content version). Gems -//! and content switches are also typed durable facts ([`GemCollected`], -//! [`ContentApplied`]) stamped by the host. The browser client is generated -//! from [`game_surface`]. - -pub mod content; -pub mod world; - -use std::collections::{BTreeMap, HashSet}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use distributed_game::net::{GameServer, ServerConfig}; -use distributed_game::{ - schema, Ctx, EntitySchema, FactCommand, FactStamp, GameEvent, GameInput, GameRecord, - GameSurface, InputCredits, LeaveReason, PlayerId, Replicated, StalePolicy, View, WorldView, - Zone, -}; -use serde::Serialize; - -use crate::common::{Demo, Guests}; -use content::{Hazard, Pack, ZoneKind}; -/// The shared movement step (Mob Pit's capsule without doorways). -pub use game_sim::arena as movement; -use movement::{MoveInput, MoveState}; - -/// A player's capsule: the full [`MoveState`] (so the client can rebase its -/// prediction) plus the display name. -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "player", id = 1, priority = 2.0, global)] -pub struct PlayerView { - /// Position x. - #[field_type(movement::POS)] - #[correct(ease = 150)] - pub x: f32, - /// Position y. - #[field_type(movement::POS)] - #[correct(ease = 150)] - pub y: f32, - /// Velocity x. - #[field_type(movement::VEL)] - #[correct(snap)] - pub vx: f32, - /// Velocity y. - #[field_type(movement::VEL)] - #[correct(snap)] - pub vy: f32, - /// Remaining dash frames. - #[correct(snap)] - pub dash: u8, - /// Remaining dash cooldown frames. - #[correct(snap)] - pub cd: u8, - /// Display name. - #[priority(0.2)] - #[correct(replace)] - pub name: String, -} - -impl PlayerView { - /// The view of a capsule. - pub fn of(state: &MoveState, name: &str) -> Self { - Self { - x: state.x, - y: state.y, - vx: state.vx, - vy: state.vy, - dash: state.dash, - cd: state.cd, - name: name.to_string(), - } - } -} - -/// Most command frames a player may bank (absorbs input jitter). -pub const MAX_CREDITS: u8 = 4; - -/// A wandering slime. -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "slime", id = 2)] -pub struct SlimeView { - /// Position x. - #[quant(min = -movement::HALF, max = movement::HALF, bits = 14)] - pub x: f32, - /// Position y. - #[quant(min = -movement::HALF, max = movement::HALF, bits = 14)] - pub y: f32, - /// Heading. - #[angle(bits = 8)] - pub yaw: f32, -} - -/// A gem waiting to be collected (global). -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "gem", id = 3, global)] -pub struct GemView { - /// Position x. - #[quant(min = -movement::HALF, max = movement::HALF, bits = 14)] - pub x: f32, - /// Position y. - #[quant(min = -movement::HALF, max = movement::HALF, bits = 14)] - pub y: f32, -} - -/// The ritual circle (global; `stuck` = 1 while it holds the zone). -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "ritual", id = 4, global)] -pub struct RitualView { - /// Position x. - #[quant(min = -movement::HALF, max = movement::HALF, bits = 14)] - pub x: f32, - /// Position y. - #[quant(min = -movement::HALF, max = movement::HALF, bits = 14)] - pub y: f32, - /// 1 while the ritual refuses the freeze. - pub stuck: u8, -} - -/// Provenance of a gameplay event: which zone, tick and content version -/// produced it. -#[derive(Debug, Clone, PartialEq, GameRecord)] -pub struct Stamp { - /// Zone id. - pub zone: String, - /// Zone tick. - pub tick: u64, - /// Content version in effect. - pub content_version: String, -} - -impl From for Stamp { - fn from(stamp: FactStamp) -> Self { - Self { - zone: stamp.zone_id, - tick: stamp.tick, - content_version: stamp.content_version, - } - } -} - -/// The Freeze's events. -#[derive(Debug, Clone, PartialEq, GameEvent)] -pub enum FreezeEvent { - /// To a joining player (also after a handoff). - #[game_event(id = 1)] - Hello { - /// Own entity id. - entity: u32, - /// Zone id. - zone: String, - /// Zone title. - title: String, - /// This zone is the safe-room gate. - gate: bool, - /// Host id (`alpha` / `beta`). - host: String, - /// Floor half extent (m). - half: f32, - /// Content version the zone runs. - content_version: String, - }, - /// Someone collected a gem. - #[game_event(id = 2)] - Gem { - /// Collector's name. - by: String, - /// Gem value under the zone's content pack. - value: u32, - /// Provenance. - stamp: Stamp, - }, - /// Someone stepped into lava. - #[game_event(id = 3)] - Burned { - /// Who. - name: String, - /// Provenance. - stamp: Stamp, - }, - /// The zone switched content. - #[game_event(id = 4)] - Content { - /// Previous version. - from: String, - /// New version. - to: String, - /// New pack name. - name: String, - /// Provenance. - stamp: Stamp, - }, -} - -/// The Freeze's client surface. -pub fn game_surface() -> GameSurface { - GameSurface::for_zone::("freeze") - .events::() - .predict::() - .record::() - .constant("freeze_world", &content::freeze_world()) - .constant("arena_floor", &movement::arena_floor()) -} - -/// Durable command recording a collected gem. -pub const GEM_COLLECTED: &str = "freeze.gem_collected"; -/// Durable command recording a content switch. -pub const CONTENT_APPLIED: &str = "freeze.content_applied"; - -/// [`GEM_COLLECTED`] input: *which* gem spot was collected. Its value is a -/// rule of the content pack at the fact's stamped content version, for the -/// handler to apply — the zone never reports a reward amount. -#[derive(Debug, Clone, PartialEq, Serialize, FactCommand)] -#[fact(command = GEM_COLLECTED)] -pub struct GemCollected { - /// Gem spot of the zone layout. - pub spot: u32, -} - -/// [`CONTENT_APPLIED`] input (a zone/system fact). -#[derive(Debug, Clone, PartialEq, Serialize, FactCommand)] -#[fact(command = CONTENT_APPLIED, key(to))] -pub struct ContentApplied { - /// Previous version. - pub from: String, - /// New version. - pub to: String, -} -/// Content version every host starts on. -pub const INITIAL_VERSION: &str = "v1"; -const VIEW_RADIUS: f32 = 40.0; -const PORTAL_RADIUS: f32 = 1.2; -const GEM_RADIUS: f32 = 0.9; -const GEM_RESPAWN: Duration = Duration::from_secs(4); -const RITUAL_ID: u32 = 900; - -/// Zones whose ritual blocks a freeze (the "stuck zone" toggle), shared by -/// a host's zones and its dev routes. -#[derive(Clone, Default)] -pub struct Rituals(Arc>>); - -impl Rituals { - /// Start / stop the ritual of `zone`. - pub fn set(&self, zone: &str, stuck: bool) { - let mut set = self.0.lock().expect("rituals"); - if stuck { - set.insert(zone.to_string()); - } else { - set.remove(zone); - } - } - - /// True while `zone` refuses to freeze. - pub fn is_stuck(&self, zone: &str) -> bool { - self.0.lock().expect("rituals").contains(zone) - } - - /// Stuck zones. - pub fn list(&self) -> Vec { - let mut v: Vec<_> = self.0.lock().expect("rituals").iter().cloned().collect(); - v.sort(); - v - } -} - -/// Zone construction parameters. -#[derive(Clone)] -pub struct FreezeConfig { - /// Which zone this is. - pub kind: ZoneKind, - /// Display names registered by the dev ticket endpoint (primary host). - pub guests: Guests, - /// Stuck-zone toggles of this host. - pub rituals: Rituals, - /// Host id shown to players (`alpha` / `beta`). - pub host: String, -} - -struct Player { - entity: u32, - name: String, - state: MoveState, - /// Just arrived through a portal: ignore portals until it stepped off. - on_portal: bool, -} - -struct Slime { - x: f32, - y: f32, - tx: f32, - ty: f32, - yaw: f32, -} - -struct Gem { - spot: usize, - /// Simulation time it respawns at (None = present). - respawn_at: Option, -} - -/// One zone of The Freeze. -pub struct FreezeZone { - config: FreezeConfig, - zone_id: String, - pack: Pack, - players: BTreeMap, - credits: InputCredits, - slimes: Vec, - gems: Vec, - collected: u64, -} - -impl FreezeZone { - /// Content pack in effect. - pub fn pack(&self) -> &Pack { - &self.pack - } - - /// Player position. - pub fn player_position(&self, player: PlayerId) -> Option<(f32, f32)> { - self.players.get(&player).map(|p| (p.state.x, p.state.y)) - } - - fn arrival_point(&self, from_zone: &str) -> (f32, f32) { - self.config - .kind - .portals() - .iter() - .find(|(_, _, to)| *to == from_zone) - .map(|(x, y, _)| (*x - x.signum() * 3.0, *y)) - .unwrap_or(content::SPAWNS[0]) - } - - fn add_player( - &mut self, - ctx: &mut Ctx<'_, Self>, - player: PlayerId, - name: String, - at: (f32, f32), - ) { - let entity = 1000 + player.0; - self.players.insert( - player, - Player { - entity, - name, - state: MoveState { - x: at.0, - y: at.1, - ..MoveState::default() - }, - on_portal: false, - }, - ); - ctx.set_view(player, View::new(at.0, at.1, VIEW_RADIUS)); - let hello = FreezeEvent::Hello { - entity, - zone: self.zone_id.clone(), - title: self.config.kind.title().into(), - gate: self.config.kind == ZoneKind::Hub, - host: self.config.host.clone(), - half: movement::HALF, - content_version: ctx.content_version().into(), - }; - ctx.send(player, &hello); - } - - fn random_point(ctx: &mut Ctx<'_, Self>) -> (f32, f32) { - loop { - let x = (ctx.rng().next_f32() * 2.0 - 1.0) * (movement::HALF - 2.0); - let y = (ctx.rng().next_f32() * 2.0 - 1.0) * (movement::HALF - 2.0); - if movement::PILLARS - .iter() - .all(|(px, py, pr)| (x - px).powi(2) + (y - py).powi(2) > (pr + 1.0).powi(2)) - { - return (x, y); - } - } - } -} - -impl Zone for FreezeZone { - type Config = FreezeConfig; - type Input = MoveInput; - type Timer = (); - type Message = distributed_game::NoMessages; - - fn schema() -> EntitySchema { - schema![PlayerView, SlimeView, GemView, RitualView] - } - - fn on_create(ctx: &mut Ctx<'_, Self>, config: FreezeConfig) -> Self { - let pack = content::pack(ctx.content_version()) - .unwrap_or_else(|| content::pack(INITIAL_VERSION).expect("v1")); - let kind = config.kind; - let mut slimes = Vec::new(); - for _ in 0..kind.slimes() { - let (x, y) = Self::random_point(ctx); - let (tx, ty) = Self::random_point(ctx); - slimes.push(Slime { - x, - y, - tx, - ty, - yaw: 0.0, - }); - } - Self { - zone_id: ctx.zone_id().to_string(), - config, - pack, - players: BTreeMap::new(), - credits: InputCredits::new(u32::from(MAX_CREDITS)), - slimes, - gems: (0..kind.gem_spots().len()) - .map(|spot| Gem { - spot, - respawn_at: None, - }) - .collect(), - collected: 0, - } - } - - fn on_join(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId) { - let principal = ctx - .player(player) - .map(|p| p.principal.clone()) - .unwrap_or_default(); - let name = self - .config - .guests - .name(&principal) - .unwrap_or_else(|| format!("Guest {}", principal.get(6..10).unwrap_or(""))); - let at = content::SPAWNS[player.0 as usize % content::SPAWNS.len()]; - self.add_player(ctx, player, name, at); - } - - fn on_handoff_join( - &mut self, - ctx: &mut Ctx<'_, Self>, - player: PlayerId, - from_zone: &str, - carried: Vec, - ) { - // Carried: the display name (the second host has no guest registry). - let name = String::from_utf8(carried) - .ok() - .filter(|n| !n.is_empty()) - .unwrap_or_else(|| "Traveller".into()); - let at = self.arrival_point(from_zone); - self.add_player(ctx, player, name, at); - } - - fn on_leave(&mut self, _ctx: &mut Ctx<'_, Self>, player: PlayerId, _reason: LeaveReason) { - self.players.remove(&player); - self.credits.remove(player); - } - - fn decode_input(&self, _player: PlayerId, payload: &[u8]) -> Option { - MoveInput::from_payload(payload) - } - - fn on_tick(&mut self, ctx: &mut Ctx<'_, Self>) { - let kind = self.config.kind; - let now = ctx.sim_time(); - let ids: Vec = self.players.keys().copied().collect(); - for id in ids { - let player = self.players.get_mut(&id).expect("present"); - for input in self.credits.take(ctx, id) { - // The declared predicted step: what the client runs as WASM. - player.state = movement::arena_step(&player.state, &input.input, &(), movement::DT); - } - let (x, y) = (player.state.x, player.state.y); - // Lava burns (ice is safe): back to a spawn point. - if self.pack.hazard == Hazard::Lava - && kind - .pools() - .iter() - .any(|(px, py, pr)| (x - px).powi(2) + (y - py).powi(2) < (pr - 0.3).powi(2)) - { - let (sx, sy) = content::SPAWNS[id.0 as usize % content::SPAWNS.len()]; - player.state = MoveState { - x: sx, - y: sy, - ..MoveState::default() - }; - let event = FreezeEvent::Burned { - name: player.name.clone(), - stamp: ctx.stamp().into(), - }; - ctx.broadcast(&event); - } - let player = self.players.get_mut(&id).expect("present"); - let (x, y) = (player.state.x, player.state.y); - ctx.set_view(id, View::new(x, y, VIEW_RADIUS)); - // Gems. - for gem in &mut self.gems { - let (gx, gy) = kind.gem_spots()[gem.spot]; - if gem.respawn_at.is_none() - && (x - gx).powi(2) + (y - gy).powi(2) < GEM_RADIUS.powi(2) - { - gem.respawn_at = Some(now + GEM_RESPAWN); - self.collected += 1; - // Keyed by tick + emission order (a spot respawns). - let spot = gem.spot as u32; - let _ = ctx.emit(id, GemCollected { spot }); - let event = FreezeEvent::Gem { - by: player.name.clone(), - value: self.pack.gem_value, - stamp: ctx.stamp().into(), - }; - ctx.broadcast(&event); - } - } - // Portals. - let portal = kind - .portals() - .iter() - .find(|(px, py, _)| (x - px).powi(2) + (y - py).powi(2) < PORTAL_RADIUS.powi(2)); - match portal { - Some((_, _, target)) if !player.on_portal => { - player.on_portal = true; - let _ = ctx.handoff(id, target, player.name.clone().into_bytes()); - } - Some(_) => {} - None => player.on_portal = false, - } - } - for gem in &mut self.gems { - if gem.respawn_at.is_some_and(|at| now >= at) { - gem.respawn_at = None; - } - } - let dt = ctx.dt_secs(); - let speed = self.pack.slime_speed; - for i in 0..self.slimes.len() { - let s = &self.slimes[i]; - let (ox, oy) = (s.tx - s.x, s.ty - s.y); - let dist = (ox * ox + oy * oy).sqrt(); - if dist < 0.3 { - let (tx, ty) = Self::random_point(ctx); - self.slimes[i].tx = tx; - self.slimes[i].ty = ty; - continue; - } - let s = &mut self.slimes[i]; - let stride = (speed * dt).min(dist); - s.x += ox / dist * stride; - s.y += oy / dist * stride; - s.yaw = oy.atan2(ox); - } - } - - fn replicate(&self, out: &mut WorldView) { - for p in self.players.values() { - // The Arena's capsule, but culled by the view radius here. - out.add(p.entity, &PlayerView::of(&p.state, &p.name)) - .local(); - } - for (i, s) in self.slimes.iter().enumerate() { - let view = SlimeView { - x: s.x, - y: s.y, - yaw: s.yaw, - }; - out.add(1 + i as u32, &view); - } - let spots = self.config.kind.gem_spots(); - for gem in self.gems.iter().filter(|g| g.respawn_at.is_none()) { - let (x, y) = spots[gem.spot]; - out.add(100 + gem.spot as u32, &GemView { x, y }); - } - let (x, y) = self.config.kind.ritual(); - let stuck = u8::from(self.config.rituals.is_stuck(&self.zone_id)); - out.add(RITUAL_ID, &RitualView { x, y, stuck }); - } - - fn is_gate(&self) -> bool { - self.config.kind == ZoneKind::Hub - } - - fn freeze_ready(&self) -> bool { - !self.config.rituals.is_stuck(&self.zone_id) - } - - fn on_content_version_changed(&mut self, ctx: &mut Ctx<'_, Self>, from: &str, to: &str) { - if let Some(pack) = content::pack(to) { - self.pack = pack; - } - // A meaningful world transition: persisted (stamped with `to`). - let _ = ctx.emit_zone(ContentApplied { - from: from.into(), - to: to.into(), - }); - let event = FreezeEvent::Content { - from: from.into(), - to: to.into(), - name: self.pack.name.clone(), - stamp: ctx.stamp().into(), - }; - ctx.broadcast(&event); - } - - fn has_pending_work(&self) -> bool { - false - } -} - -/// Host settings every Freeze server uses: content version `v1`, safe-room -/// stale policy. -pub fn freeze_config(mut config: ServerConfig) -> ServerConfig { - config.host.content_version = INITIAL_VERSION.into(); - config.host.stale_policy = StalePolicy::SafeRoom { roam: false }; - config -} - -/// Demo registration (`game-demos freeze`). [`Demo::server`] builds a -/// single-host world (every zone local); `game-demos freeze` runs the -/// two-host world of [`world`]. -pub struct FreezeDemo; - -impl Demo for FreezeDemo { - type Zone = FreezeZone; - const NAME: &'static str = "freeze"; - const DEFAULT_ZONE: &'static str = content::HUB; - - fn hosts_zone(zone_id: &str) -> bool { - ZoneKind::of(zone_id).is_some() - } - - fn server(config: ServerConfig, guests: Guests) -> GameServer { - world::zone_server(freeze_config(config), guests, Rituals::default(), "local") - } -} - -/// Bot payload for `distributed_game::bots`: direction → [`MoveInput`]. -pub fn encode_bot_input(dir: [f32; 2]) -> Vec { - let axis = |v: f32| (v.clamp(-1.0, 1.0) * 127.0).round() as i8; - MoveInput { - mx: axis(dir[0]), - my: axis(dir[1]), - buttons: 0, - } - .to_payload() -} diff --git a/examples/game/server/src/freeze/world.rs b/examples/game/server/src/freeze/world.rs deleted file mode 100644 index e0089899..00000000 --- a/examples/game/server/src/freeze/world.rs +++ /dev/null @@ -1,768 +0,0 @@ -//! The two-host world of **The Freeze** and its dev / admin routes. -//! -//! `game-demos freeze --dev` is the **primary** host (`alpha`: hub + -//! meadow). It starts a **second process** of itself for `beta` (Ember -//! Fields) with `--peer alpha=`, sharing the ticket secret and the -//! admin secret through the environment; the child exits when the -//! primary's pipe closes. Both hosts share a `StaticDirectory`, so a Hello -//! for a zone on the other host is redirected and handoffs reserve their -//! transfer with the issuing host over `/admin/transfer`. -//! -//! Facts go to celld (`--celld-url` / `CELLD_URL`, `DISTRIBUTED_INTERNAL_SECRET`, -//! the `examples/game/cells` worker; the secondary inherits both through the -//! environment) with [`FreezeFactSink`]: `freeze.gem_collected` to the -//! player's `gem-pouch/{principal}` cell, `freeze.content_applied` to the -//! zone's `content-log/{zone}` cell. The host keeps the last settled facts -//! in memory only for `GET /dev/freeze/world`. -//! -//! Routes (dev only, mounted with `--dev`; the mutating ones require the -//! `x-game-admin-secret` header): -//! -//! | Route | Effect | -//! |---|---| -//! | `GET /dev/freeze/content/{version}` | content pack, payload-codec encoded (`application/octet-stream`, decoded by the generated `decodePack`; `current` = the world's version; future versions 404 with JSON) | -//! | `GET /dev/freeze/world` | status of every host's zones (freeze state, players, inputs dropped while frozen, recent facts) | -//! | `POST /dev/freeze/rollout` `{mode: hard\|rolling}` | **admin** — primary only: `WorldCoordinator::rollout` to the next version | -//! | `POST /dev/freeze/stuck` `{zone, stuck}` | **admin** — start/stop a zone's ritual (forwarded to the owning host) | -//! | `POST /dev/freeze/host-status` | **admin** — this host's status (peer call) | - -use std::future::Future; -use std::io::{BufRead, BufReader}; -use std::net::SocketAddr; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use axum::extract::{Path, State}; -use axum::http::{HeaderMap, StatusCode}; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use distributed_game::celld::{CelldFactSink, FactRoute}; -use distributed_game::net::peer::{post_json, ADMIN_SECRET_HEADER}; -use distributed_game::net::{ - GameServer, RemoteHost, RolloutError, ServerConfig, TicketKey, WorldCoordinator, ZoneFactory, -}; -use distributed_game::world::{HostAddr, StaticDirectory}; -use distributed_game::{ - BarrierMode, BoxFuture, Fact, FactError, FactReceipt, FactSink, FreezeState, RetryPolicy, -}; -use serde::Deserialize; -use serde_json::{json, Value}; - -use super::content::{self, ZoneKind, EMBER, HUB, MEADOW}; -use super::{ - freeze_config, ContentApplied, FreezeConfig, FreezeDemo, FreezeZone, GemCollected, Rituals, -}; -use crate::common::{self, dev, random_hex, server_config, CelldOptions, DemoArgs, Guests}; - -/// Command-ledger service id of Freeze facts. -pub const SERVICE_ID: &str = "freeze"; -/// Principal of zone facts (`freeze.content_applied`). -pub const SYSTEM_PRINCIPAL: &str = "freeze-zone"; - -/// The Freeze's fact sink: facts to their celld cells, the settled ones -/// mirrored (last 64) for the dev status route. -#[derive(Clone)] -pub struct FreezeFactSink { - celld: CelldFactSink, - settled: Arc>>, -} - -impl FreezeFactSink { - /// Sink into the cells at `celld`. - pub fn new(celld: &CelldOptions) -> Result { - let sink = CelldFactSink::celld( - celld.url.clone(), - celld.secret.clone(), - SERVICE_ID, - SYSTEM_PRINCIPAL, - )? - .route(FactRoute::by_principal::("gem-pouch")) - .route(FactRoute::by_zone::("content-log")); - Ok(Self { - celld: sink, - settled: Arc::default(), - }) - } - - /// Settled facts, oldest first (at most the last 64). - pub fn committed(&self) -> Vec { - self.settled - .lock() - .expect("settled") - .iter() - .cloned() - .collect() - } -} - -impl FactSink for FreezeFactSink { - fn commit<'a>(&'a self, fact: &'a Fact) -> BoxFuture<'a, Result> { - Box::pin(async move { - let receipt = self.celld.commit(fact).await?; - let mut settled = self.settled.lock().expect("settled"); - if settled.len() == 64 { - settled.pop_front(); - } - settled.push_back(fact.clone()); - Ok(receipt) - }) - } -} - -/// Default time the "content CDN" takes to stage an update while the world -/// is frozen (makes the pause visible). -pub const STAGE_MS: u64 = 2500; -/// How long the coordinator waits for every zone to freeze. -pub const ACK_TIMEOUT: Duration = Duration::from_secs(3); - -/// `game-demos freeze` options. -#[derive(Debug, Clone, clap::Args)] -pub struct FreezeArgs { - #[command(flatten)] - pub demo: DemoArgs, - /// Shared admin secret (barrier, transfers, `/dev/freeze/*` admin - /// routes). Default: random, printed at startup. - #[arg(long, env = "DG_DEMO_ADMIN_SECRET", hide_env_values = true)] - pub admin_secret: Option, - /// This host's id. - #[arg(long, default_value = "alpha")] - pub host_id: String, - /// Run as the second host of `id=host:port` (set by the primary). - #[arg(long)] - pub peer: Option, - /// Simulated content staging time during the freeze (ms). - #[arg(long, env = "DG_FREEZE_STAGE_MS", default_value_t = STAGE_MS)] - pub stage_ms: u64, - /// celld serving the `gem-pouch` and `content-log` cells. - #[arg(long, env = "CELLD_URL")] - pub celld_url: String, - /// Internal secret for the celld cells. - #[arg(long, env = "DISTRIBUTED_INTERNAL_SECRET", hide_env_values = true)] - pub internal_secret: String, -} - -/// Everything one host needs. -pub struct HostSpec { - /// This host. - pub me: HostAddr, - /// The other hosts. - pub peers: Vec, - /// Zone → host id. - pub assignment: Vec<(String, String)>, - /// Ticket key shared by every host. - pub key: TicketKey, - /// Admin secret shared by every host. - pub admin_secret: String, - /// Mount the dev routes. - pub dev: bool, - /// Content staging delay inside the barrier. - pub stage: Duration, - /// Barrier acknowledgement timeout. - pub ack_timeout: Duration, - /// celld holding the facts' cells. - pub celld: CelldOptions, -} - -/// A running Freeze host. -pub struct WorldHost { - /// Bound address. - pub addr: SocketAddr, - /// The game server. - pub server: GameServer, - /// Facts committed by this host's zones. - pub sink: FreezeFactSink, - /// Stuck-zone toggles. - pub rituals: Rituals, - /// Resolves when the server stopped. - pub task: tokio::task::JoinHandle>, -} - -/// The Freeze game server for one host (no routes, no directory). -pub fn zone_server( - config: ServerConfig, - guests: Guests, - rituals: Rituals, - host: &str, -) -> GameServer { - let celld = CelldOptions::from_env().expect("The Freeze needs celld"); - let sink = FreezeFactSink::new(&celld).expect("freeze fact sink"); - zone_server_with_sink(config, guests, rituals, host, sink) -} - -fn zone_server_with_sink( - config: ServerConfig, - guests: Guests, - rituals: Rituals, - host: &str, - sink: FreezeFactSink, -) -> GameServer { - let host = host.to_string(); - GameServer::new( - config, - ZoneFactory::sync(move |activation| { - let zone_id = activation.zone_id.as_str(); - ZoneKind::of(zone_id).map(|kind| FreezeConfig { - kind, - guests: guests.clone(), - rituals: rituals.clone(), - host: host.clone(), - }) - }), - ) - // celld down: facts wait in the zone's queue until they settle. - .with_fact_sink( - sink, - 256, - RetryPolicy::until_settled(Duration::from_millis(50), Duration::from_secs(2)), - ) -} - -/// The default assignment: hub + meadow on `primary`, ember on `secondary`. -pub fn assignment(primary: &str, secondary: &str) -> Vec<(String, String)> { - vec![ - (HUB.into(), primary.into()), - (MEADOW.into(), primary.into()), - (EMBER.into(), secondary.into()), - ] -} - -/// Start one host on `listener` until `signal` resolves; activates its -/// zones so the barrier reaches them before anyone joined. -pub fn start_host( - listener: tokio::net::TcpListener, - spec: HostSpec, - signal: impl Future + Send + 'static, -) -> std::io::Result { - let addr = listener.local_addr()?; - let directory = Arc::new(StaticDirectory::new()); - directory.add_host(spec.me.clone()); - for peer in &spec.peers { - directory.add_host(peer.clone()); - } - for (zone, host) in &spec.assignment { - directory.assign(zone.clone(), host.clone()); - } - let mut config = freeze_config(server_config(spec.key.clone(), None)); - config.admin_secret = Some(spec.admin_secret.clone()); - let guests = Guests::default(); - let rituals = Rituals::default(); - let sink = FreezeFactSink::new(&spec.celld).map_err(std::io::Error::other)?; - let server = zone_server_with_sink( - config, - guests.clone(), - rituals.clone(), - &spec.me.id, - sink.clone(), - ) - .with_host_id(spec.me.id.clone()) - .with_directory(directory); - let local: Vec = spec - .assignment - .iter() - .filter(|(_, h)| *h == spec.me.id) - .map(|(z, _)| z.clone()) - .collect(); - let extra = if spec.dev { - dev::routes::(server.clone(), spec.key.clone(), guests).merge(routes( - FreezeRoutes { - server: server.clone(), - sink: sink.clone(), - rituals: rituals.clone(), - admin_secret: spec.admin_secret.clone(), - me: spec.me.clone(), - peers: spec.peers.clone(), - local: local.clone(), - assignment: spec.assignment.clone(), - stage: spec.stage, - ack_timeout: spec.ack_timeout, - primary: spec - .assignment - .iter() - .any(|(z, h)| z == HUB && *h == spec.me.id), - rollout: Arc::new(tokio::sync::Mutex::new(())), - history: Arc::new(Mutex::new(Vec::new())), - }, - )) - } else { - Router::new() - }; - let task = tokio::spawn(server.clone().serve_with(listener, extra, signal)); - for zone in &local { - server.begin_activation(zone); - } - Ok(WorldHost { - addr, - server, - sink, - rituals, - task, - }) -} - -// ---------------------------------------------------------------- routes - -#[derive(Clone)] -struct FreezeRoutes { - server: GameServer, - sink: FreezeFactSink, - rituals: Rituals, - admin_secret: String, - me: HostAddr, - peers: Vec, - local: Vec, - assignment: Vec<(String, String)>, - stage: Duration, - ack_timeout: Duration, - primary: bool, - rollout: Arc>, - history: Arc>>, -} - -type Reply = (StatusCode, Json); - -fn reply(status: StatusCode, body: Value) -> Reply { - (status, Json(body)) -} - -fn authorized(state: &FreezeRoutes, headers: &HeaderMap) -> Result<(), Reply> { - let presented = headers - .get(ADMIN_SECRET_HEADER) - .map(|v| v.as_bytes()) - .unwrap_or_default(); - let expected = state.admin_secret.as_bytes(); - let same = presented.len() == expected.len() - && presented - .iter() - .zip(expected) - .fold(0u8, |acc, (a, b)| acc | (a ^ b)) - == 0; - if same && !expected.is_empty() { - Ok(()) - } else { - Err(reply( - StatusCode::UNAUTHORIZED, - json!({ "ok": false, "error": "admin secret required (x-game-admin-secret)" }), - )) - } -} - -fn routes(state: FreezeRoutes) -> Router { - Router::new() - .route("/dev/freeze/content/{version}", get(content_pack)) - .route("/dev/freeze/world", get(world_status)) - .route("/dev/freeze/host-status", post(host_status)) - .route("/dev/freeze/rollout", post(rollout)) - .route("/dev/freeze/stuck", post(stuck)) - .with_state(state) -} - -async fn content_pack( - State(state): State, - Path(version): Path, -) -> axum::response::Response { - use axum::response::IntoResponse; - let current = state.server.content_version(); - let wanted = if version == "current" { - current.clone() - } else { - version - }; - let published = content::version_number(&wanted) - .zip(content::version_number(¤t)) - .is_some_and(|(w, c)| w <= c); - match content::pack(&wanted).filter(|_| published) { - Some(pack) => ( - StatusCode::OK, - [(axum::http::header::CONTENT_TYPE, "application/octet-stream")], - pack.to_bytes(), - ) - .into_response(), - None => reply( - StatusCode::NOT_FOUND, - json!({ "error": format!("content {wanted} is not published (world runs {current})") }), - ) - .into_response(), - } -} - -async fn local_status(state: &FreezeRoutes) -> Value { - let mut zones = Vec::new(); - for zone in &state.local { - let info = state - .server - .inspect(zone, |h| { - ( - h.freeze_state(), - h.player_count(), - h.inputs_dropped_frozen(), - h.config().content_version.clone(), - h.zone().pack().name.clone(), - ) - }) - .await; - if let Some((freeze, players, dropped, version, pack)) = info { - zones.push(json!({ - "zone": zone, - "title": ZoneKind::of(zone).map(ZoneKind::title), - "state": match freeze { - FreezeState::Running => "running", - FreezeState::Freezing => "freezing", - FreezeState::Frozen => "frozen", - }, - "players": players, - "droppedFrozen": dropped, - "contentVersion": version, - "pack": pack, - "stuck": state.rituals.is_stuck(zone), - })); - } - } - let facts: Vec = state - .sink - .committed() - .iter() - .rev() - .take(8) - .map(|f| { - json!({ - "command": f.command(), - "key": f.key(), - "principal": f.principal(), - "input": f.input(), - "stamp": { - "zone": f.stamp().zone_id, - "tick": f.stamp().tick, - "contentVersion": f.stamp().content_version, - }, - }) - }) - .collect(); - json!({ - "host": state.me.id, - "version": state.server.content_version(), - "zones": zones, - "facts": facts, - }) -} - -async fn host_status(State(state): State, headers: HeaderMap) -> Reply { - if let Err(r) = authorized(&state, &headers) { - return r; - } - reply(StatusCode::OK, local_status(&state).await) -} - -async fn world_status(State(state): State) -> Reply { - let mut hosts = vec![local_status(&state).await]; - for peer in &state.peers { - match post_json( - &peer.admin_url, - "/dev/freeze/host-status", - &state.admin_secret, - &json!({}), - Duration::from_secs(2), - ) - .await - { - Ok((200, status)) => hosts.push(status), - other => hosts.push(json!({ "host": peer.id, "error": format!("{other:?}") })), - } - } - let history = state.history.lock().expect("history").clone(); - reply( - StatusCode::OK, - json!({ "version": state.server.content_version(), "hosts": hosts, "rollouts": history }), - ) -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct RolloutRequest { - /// `hard` or `rolling` (safe room). - mode: String, -} - -async fn rollout( - State(state): State, - headers: HeaderMap, - Json(body): Json, -) -> Reply { - if let Err(r) = authorized(&state, &headers) { - return r; - } - if !state.primary { - return reply( - StatusCode::BAD_REQUEST, - json!({ "ok": false, "error": "roll out from the primary host" }), - ); - } - let mode = match body.mode.as_str() { - "hard" => BarrierMode::Hard, - "rolling" => BarrierMode::Rolling, - _ => { - return reply( - StatusCode::BAD_REQUEST, - json!({ "ok": false, "error": "mode must be hard or rolling" }), - ) - } - }; - let Ok(_running) = state.rollout.try_lock() else { - return reply( - StatusCode::CONFLICT, - json!({ "ok": false, "error": "a rollout is in progress" }), - ); - }; - let current = state.server.content_version(); - let next = content::version_name(content::version_number(¤t).unwrap_or(1) + 1); - let pack = content::pack(&next).expect("valid version"); - let notes = pack.resume_notes(); - let mut coordinator = - WorldCoordinator::new(state.ack_timeout).participant(Arc::new(state.server.clone())); - for peer in &state.peers { - coordinator = coordinator.participant(Arc::new(RemoteHost::new( - peer.clone(), - state.admin_secret.clone(), - ))); - } - let started = Instant::now(); - // The "content CDN" stages the pack while every zone is frozen: an - // async apply, so clients see the SYSTEM "applying update" pause. - let stage = state.stage; - let result = coordinator - .rollout_async(&next, ¬es, mode, |_version| async move { - tokio::time::sleep(stage).await; - Ok(()) - }) - .await; - let took_ms = started.elapsed().as_millis() as u64; - let mode_name = body.mode; - let outcome = match result { - Ok(report) => json!({ - "ok": true, "from": current, "version": report.version, "mode": mode_name, - "name": pack.name, "notes": pack.notes, "commitErrors": report.commit_errors, - "tookMs": took_ms, - }), - Err(RolloutError::NotAcked(missing)) => json!({ - "ok": false, "error": "not_acked", "missing": missing, "attempted": next, - "version": current, "mode": mode_name, "tookMs": took_ms, - }), - Err(RolloutError::ApplyFailed(e)) => json!({ - "ok": false, "error": "apply_failed", "detail": e, "attempted": next, - "version": current, "mode": mode_name, "tookMs": took_ms, - }), - }; - let mut history = state.history.lock().expect("history"); - history.push(outcome.clone()); - if history.len() > 10 { - history.remove(0); - } - reply(StatusCode::OK, outcome) -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct StuckRequest { - zone: String, - stuck: bool, -} - -async fn stuck( - State(state): State, - headers: HeaderMap, - Json(body): Json, -) -> Reply { - if let Err(r) = authorized(&state, &headers) { - return r; - } - let Some(owner) = state - .assignment - .iter() - .find(|(z, _)| *z == body.zone) - .map(|(_, h)| h.clone()) - else { - return reply( - StatusCode::BAD_REQUEST, - json!({ "ok": false, "error": "unknown zone" }), - ); - }; - if owner == state.me.id { - state.rituals.set(&body.zone, body.stuck); - return reply( - StatusCode::OK, - json!({ "ok": true, "host": owner, "stuck": state.rituals.list() }), - ); - } - let Some(peer) = state.peers.iter().find(|p| p.id == owner) else { - return reply( - StatusCode::BAD_GATEWAY, - json!({ "ok": false, "error": "owner unknown" }), - ); - }; - match post_json( - &peer.admin_url, - "/dev/freeze/stuck", - &state.admin_secret, - &json!({ "zone": body.zone, "stuck": body.stuck }), - Duration::from_secs(2), - ) - .await - { - Ok((status, value)) => reply( - StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_GATEWAY), - value, - ), - Err(e) => reply(StatusCode::BAD_GATEWAY, json!({ "ok": false, "error": e })), - } -} - -// ---------------------------------------------------------------- CLI - -fn parse_peer(spec: &str) -> std::io::Result { - let (id, addr) = spec - .split_once('=') - .ok_or_else(|| std::io::Error::other("--peer must be id=host:port"))?; - Ok(HostAddr::new(id, addr)) -} - -/// Spawn the second host (this binary, `--peer`), wait for its address. -fn spawn_secondary( - args: &FreezeArgs, - primary: &HostAddr, - secret: &str, - admin: &str, -) -> std::io::Result<(std::process::Child, HostAddr)> { - let exe = std::env::current_exe()?; - let mut command = std::process::Command::new(exe); - command - .arg("freeze") - .args([ - "--host", - &args.demo.host, - "--port", - "0", - "--host-id", - "beta", - ]) - .arg("--peer") - .arg(format!( - "{}={}", - primary.id, - primary.admin_url.trim_start_matches("http://") - )) - .args(["--stage-ms", &args.stage_ms.to_string()]) - .args(["--celld-url", &args.celld_url]) - .env("DISTRIBUTED_INTERNAL_SECRET", &args.internal_secret) - .env("DISTRIBUTED_GAME_SECRET", secret) - .env("DG_DEMO_ADMIN_SECRET", admin) - .env_remove("GAME_DEMOS_PORT") - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::inherit()); - if args.demo.dev { - command.arg("--dev"); - } - let mut child = command.spawn()?; - let stdout = child.stdout.take().expect("piped"); - let mut lines = BufReader::new(stdout).lines(); - let addr = loop { - match lines.next() { - Some(Ok(line)) => { - if let Some(url) = line.strip_prefix("LISTENING http://") { - break url.trim().to_string(); - } - } - _ => { - let _ = child.kill(); - let _ = child.wait(); - return Err(std::io::Error::other("second host failed to start")); - } - } - }; - // Keep draining so the child never blocks on a full pipe. - std::thread::spawn(move || for _ in lines.by_ref() {}); - Ok((child, HostAddr::new("beta", addr))) -} - -/// [`common::shutdown_signal`], plus (secondary host) the primary going away: -/// the secondary exits when its stdin, held by the primary, closes. -async fn shutdown_signal(watch_stdin: bool) { - let (orphaned_tx, orphaned) = tokio::sync::oneshot::channel::<()>(); - if watch_stdin { - std::thread::spawn(move || { - let mut sink = Vec::new(); - let _ = std::io::Read::read_to_end(&mut std::io::stdin(), &mut sink); - let _ = orphaned_tx.send(()); - }); - } else { - std::mem::forget(orphaned_tx); - } - tokio::select! { - _ = common::shutdown_signal() => {} - _ = orphaned => {} - } -} - -/// `game-demos freeze`: the primary host (spawns the secondary) or, with -/// `--peer`, the secondary. -pub async fn run(args: FreezeArgs) -> std::io::Result<()> { - let listener = tokio::net::TcpListener::bind((args.demo.host.as_str(), args.demo.port)).await?; - let addr = listener.local_addr()?; - let secret = args.demo.secret.clone().unwrap_or_else(|| random_hex(32)); - let admin = args.admin_secret.clone().unwrap_or_else(|| random_hex(24)); - let me = HostAddr::new(args.host_id.clone(), addr.to_string()); - let secondary = args.peer.is_some(); - let (peers, child) = match &args.peer { - Some(spec) => (vec![parse_peer(spec)?], None), - None => { - let (child, beta) = spawn_secondary(&args, &me, &secret, &admin)?; - (vec![beta], Some(child)) - } - }; - let (primary_id, secondary_id) = if secondary { - (peers[0].id.clone(), me.id.clone()) - } else { - (me.id.clone(), peers[0].id.clone()) - }; - let spec = HostSpec { - me: me.clone(), - peers: peers.clone(), - assignment: assignment(&primary_id, &secondary_id), - key: TicketKey::new(secret), - admin_secret: admin.clone(), - dev: args.demo.dev, - stage: Duration::from_millis(args.stage_ms), - ack_timeout: ACK_TIMEOUT, - celld: CelldOptions::new(args.celld_url.clone(), args.internal_secret.clone()) - .map_err(std::io::Error::other)?, - }; - let host = start_host(listener, spec, shutdown_signal(secondary))?; - // Parsed by run.sh and the client tests: keep the format. - println!("LISTENING http://{}", host.addr); - if secondary { - println!("freeze host {} (Ember Fields)", me.id); - } else { - println!( - "demo freeze · zones {HUB} {MEADOW} @{} + {EMBER} @{} (second process) · admin secret {admin} (admin panel: /freeze/?admin={admin}) · dev routes {}", - me.id, - peers[0].admin_url, - if args.demo.dev { "ON" } else { "off" } - ); - } - let result = host.task.await.map_err(std::io::Error::other)?; - if let Some(mut child) = child { - // Closing its stdin is the shutdown signal; kill as a fallback. - drop(child.stdin.take()); - let deadline = Instant::now() + Duration::from_secs(3); - while Instant::now() < deadline { - if child.try_wait()?.is_some() { - break; - } - tokio::time::sleep(Duration::from_millis(50)).await; - } - if child.try_wait()?.is_none() { - let _ = child.kill(); - let _ = child.wait(); - } - } - result -} diff --git a/examples/game/server/tests/freeze_smoke.rs b/examples/game/server/tests/freeze_smoke.rs deleted file mode 100644 index 2ac761c8..00000000 --- a/examples/game/server/tests/freeze_smoke.rs +++ /dev/null @@ -1,292 +0,0 @@ -//! The Freeze: two in-process hosts talking over their admin HTTP routes -//! (the binary runs the second host as a separate process; the client's -//! `freeze-headless` test covers that, handoffs and safe-room reloads). - -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::Duration; - -use distributed_game::bots::{run_bots, BotConfig, RandomWalk}; -use distributed_game::net::TicketKey; -use distributed_game::world::HostAddr; -use distributed_game::FreezeState; - -use game_demos::freeze::content::{Hazard, Pack, EMBER, HUB, MEADOW}; -use game_demos::freeze::world::{assignment, start_host, HostSpec, WorldHost}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; - -const SECRET: &str = "freeze-smoke-secret"; -const ADMIN: &str = "freeze-smoke-admin"; - -async fn world() -> ( - WorldHost, - WorldHost, - Vec>, - game_demos::devcelld::DevCelld, -) { - // Both hosts' facts go to one private `celld dev`. - let celld = game_demos::devcelld::DevCelld::start(None) - .unwrap_or_else(|error| panic!("celld dev: {error}")); - let la = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let lb = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let a = HostAddr::new("alpha", la.local_addr().unwrap().to_string()); - let b = HostAddr::new("beta", lb.local_addr().unwrap().to_string()); - let mut stops = Vec::new(); - let mut hosts = Vec::new(); - for (me, peer, listener) in [(a.clone(), b.clone(), la), (b, a, lb)] { - let (stop, stopped) = tokio::sync::oneshot::channel::<()>(); - stops.push(stop); - let spec = HostSpec { - me, - peers: vec![peer], - assignment: assignment("alpha", "beta"), - key: TicketKey::new(SECRET), - admin_secret: ADMIN.into(), - dev: true, - stage: Duration::from_millis(600), - ack_timeout: Duration::from_millis(800), - celld: celld.options(), - }; - hosts.push( - start_host(listener, spec, async move { - let _ = stopped.await; - }) - .unwrap(), - ); - } - let b = hosts.pop().unwrap(); - let a = hosts.pop().unwrap(); - (a, b, stops, celld) -} - -/// Minimal HTTP/1.1 client: `(status, JSON body or Null)`. -async fn request( - addr: SocketAddr, - method: &str, - path: &str, - body: Option, - admin: bool, -) -> (u16, Vec) { - let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap(); - let body = body.map(|b| b.to_string()).unwrap_or_default(); - let secret = if admin { - format!("x-game-admin-secret: {ADMIN}\r\n") - } else { - String::new() - }; - let request = format!( - "{method} {path} HTTP/1.1\r\nHost: test\r\nConnection: close\r\n{secret}Content-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", - body.len() - ); - stream.write_all(request.as_bytes()).await.unwrap(); - let mut response = Vec::new(); - stream.read_to_end(&mut response).await.unwrap(); - let status = std::str::from_utf8(&response[9..12]) - .unwrap() - .parse() - .unwrap(); - let body = response - .windows(4) - .position(|w| w == b"\r\n\r\n") - .map(|at| response[at + 4..].to_vec()) - .unwrap_or_default(); - (status, body) -} - -async fn http( - addr: SocketAddr, - method: &str, - path: &str, - body: Option, - admin: bool, -) -> (u16, Value) { - let (status, body) = request(addr, method, path, body, admin).await; - (status, serde_json::from_slice(&body).unwrap_or(Value::Null)) -} - -/// The content CDN: a pack in its payload encoding (what `decodePack` reads). -async fn content(addr: SocketAddr, version: &str) -> (u16, Option) { - let (status, body) = request( - addr, - "GET", - &format!("/dev/freeze/content/{version}"), - None, - false, - ) - .await; - (status, distributed_game::codec::from_bytes(&body).ok()) -} - -fn bots(url: String, zone: &str) -> BotConfig { - let mut config = BotConfig::new(url, zone, TicketKey::new(SECRET)); - config.bots = 2; - config.input_rate = 30.0; - config.duration = Duration::from_millis(2600); - config.content_version = "v1".into(); - config.principal_prefix = format!("bot-{zone}"); - config.encoder = Arc::new(game_demos::freeze::encode_bot_input); - config -} - -async fn dropped(host: &WorldHost, zone: &str) -> u64 { - host.server - .inspect(zone, |h| h.inputs_dropped_frozen()) - .await - .unwrap() -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn rollout_spans_both_hosts_drops_frozen_inputs_and_stamps_the_new_version() { - let (a, b, stops, celld) = world().await; - let ws_a = format!("ws://{}", a.addr); - let ws_b = format!("ws://{}", b.addr); - - // Admin routes need the secret; content of the future is not published. - let (status, _) = http( - a.addr, - "POST", - "/dev/freeze/rollout", - Some(json!({"mode": "hard"})), - false, - ) - .await; - assert_eq!(status, 401); - let (status, _) = content(a.addr, "v2").await; - assert_eq!(status, 404); - let (status, pack) = content(a.addr, "current").await; - assert_eq!( - (status, pack), - (200, game_demos::freeze::content::pack("v1")) - ); - - // Bots keep sending inputs through the freeze on both hosts. - let addr = a.addr; - let (meadow, ember, rollout) = tokio::join!( - run_bots(bots(ws_a, MEADOW), |_| Box::new(RandomWalk::new(0.1))), - run_bots(bots(ws_b, EMBER), |_| Box::new(RandomWalk::new(0.1))), - async move { - tokio::time::sleep(Duration::from_millis(700)).await; - http( - addr, - "POST", - "/dev/freeze/rollout", - Some(json!({"mode": "rolling"})), - true, - ) - .await - } - ); - assert_eq!(rollout.0, 200); - assert_eq!(rollout.1["ok"], true, "{}", rollout.1); - assert_eq!(rollout.1["version"], "v2"); - for summary in [&meadow, &ember] { - assert_eq!(summary.joined(), 2, "{:?}", summary.bots); - assert_eq!(summary.failed(), 0, "{:?}", summary.bots); - assert!(summary.bots.iter().all(|bot| bot.last_ack > 10)); - } - assert!( - dropped(&a, MEADOW).await > 0, - "frozen inputs counted on alpha" - ); - assert!( - dropped(&b, EMBER).await > 0, - "frozen inputs counted on beta (remote barrier)" - ); - - assert_eq!(a.server.content_version(), "v2"); - assert_eq!(b.server.content_version(), "v2"); - for (host, zone) in [(&a, HUB), (&a, MEADOW), (&b, EMBER)] { - let (version, pack) = host - .server - .inspect(zone, |h| { - ( - h.config().content_version.clone(), - h.zone().pack().name.clone(), - ) - }) - .await - .unwrap(); - assert_eq!((version.as_str(), pack.as_str()), ("v2", "Frostfall Patch")); - assert!(host - .sink - .committed() - .iter() - .any(|f| f.command() == "freeze.content_applied" - && f.stamp().zone_id == zone - && f.stamp().content_version == "v2")); - // Durable in the zone's celld content-log cell. - let log: Value = reqwest::Client::new() - .get(format!("{}/content-log/{zone}", celld.url())) - .header("x-distributed-internal-secret", celld.secret()) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - assert_eq!(log["switches"][0]["to"], "v2", "{zone}: {log}"); - } - let (_, pack) = content(a.addr, "current").await; - assert_eq!(pack.unwrap().hazard, Hazard::Ice); - let (_, status) = http(a.addr, "GET", "/dev/freeze/world", None, false).await; - assert_eq!(status["hosts"][1]["host"], "beta"); - assert_eq!(status["hosts"][1]["zones"][0]["contentVersion"], "v2"); - for stop in stops { - let _ = stop.send(()); - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn a_stuck_zone_aborts_and_everyone_resumes_on_the_old_version() { - let (a, b, stops, _celld) = world().await; - // The stuck toggle is forwarded to the host owning the zone. - let (status, reply) = http( - a.addr, - "POST", - "/dev/freeze/stuck", - Some(json!({"zone": EMBER, "stuck": true})), - true, - ) - .await; - assert_eq!((status, reply["host"].as_str()), (200, Some("beta"))); - assert!(b.rituals.is_stuck(EMBER)); - let (_, result) = http( - a.addr, - "POST", - "/dev/freeze/rollout", - Some(json!({"mode": "hard"})), - true, - ) - .await; - assert_eq!(result["ok"], false, "{result}"); - assert_eq!(result["missing"], json!([format!("beta/{EMBER}")])); - assert_eq!(a.server.content_version(), "v1"); - assert_eq!(b.server.content_version(), "v1"); - for (host, zone) in [(&a, HUB), (&a, MEADOW), (&b, EMBER)] { - let state = host.server.inspect(zone, |h| h.freeze_state()).await; - assert_eq!(state, Some(FreezeState::Running), "{zone}"); - } - // Unstuck: the next rollout goes through. - http( - a.addr, - "POST", - "/dev/freeze/stuck", - Some(json!({"zone": EMBER, "stuck": false})), - true, - ) - .await; - let (_, result) = http( - a.addr, - "POST", - "/dev/freeze/rollout", - Some(json!({"mode": "hard"})), - true, - ) - .await; - assert_eq!(result["version"], "v2", "{result}"); - assert_eq!(b.server.content_version(), "v2"); - for stop in stops { - let _ = stop.send(()); - } -} diff --git a/js/src/game/connection.ts b/js/src/game/connection.ts index c7a4be5b..539f3bf4 100644 --- a/js/src/game/connection.ts +++ b/js/src/game/connection.ts @@ -403,13 +403,18 @@ export class GameConnection { } /** - * Content version presented in the next Hello (reconnects, reloads). - * For clients that patch content live (e.g. after a Resume with a new - * version) without a reload round-trip; the server learns it on the - * next Hello. + * The app patched its content live to `contentVersion` (e.g. after a + * Resume with a new version), without a reload round-trip: presented in + * the next Hello (reconnects, reloads) and, on an open player + * connection, told to the server at once (`Content`), which records it + * when it is the zone's current version (the player is no longer stale). */ setContentVersion(contentVersion: string): void { + const changed = contentVersion !== this.#contentVersion; this.#contentVersion = contentVersion; + if (changed && this.#state === 'open' && this.mirror.welcome?.role === 'player') { + this.#send({ type: 'Content', contentVersion }); + } } /** diff --git a/js/src/game/messages.ts b/js/src/game/messages.ts index 03bcbfe9..6c7c4e4c 100644 --- a/js/src/game/messages.ts +++ b/js/src/game/messages.ts @@ -21,6 +21,7 @@ export const GameMessageTag = Object.freeze({ INPUT: 0x02, PING: 0x03, LEAVE: 0x04, + CONTENT: 0x05, WELCOME: 0x80, DELTA: 0x81, EVENT: 0x82, @@ -84,7 +85,9 @@ export type InputMessage = Readonly<{ }>; export type PingMessage = Readonly<{ type: 'Ping'; clientTime: number }>; export type LeaveMessage = Readonly<{ type: 'Leave' }>; -export type GameClientMessage = HelloMessage | InputMessage | PingMessage | LeaveMessage; +/** The client patched its content live and now runs `contentVersion`. */ +export type ContentMessage = Readonly<{ type: 'Content'; contentVersion: string }>; +export type GameClientMessage = HelloMessage | InputMessage | PingMessage | LeaveMessage | ContentMessage; export type GameRole = 'player' | 'observer'; @@ -177,6 +180,9 @@ export function encodeClientMessage(message: GameClientMessage): Uint8Array { case 'Leave': w.u8(GameMessageTag.LEAVE); break; + case 'Content': + w.u8(GameMessageTag.CONTENT).string(message.contentVersion); + break; } return w.finish(); } @@ -206,6 +212,9 @@ export function decodeClientMessage(bytes: Uint8Array): GameClientMessage { case GameMessageTag.LEAVE: message = { type: 'Leave' }; break; + case GameMessageTag.CONTENT: + message = { type: 'Content', contentVersion: r.string() }; + break; default: throw new GameDecodeError('UNKNOWN_MESSAGE', `message type 0x${tag.toString(16)}`); } diff --git a/js/tests/game-codec.test.mjs b/js/tests/game-codec.test.mjs index 39c96776..5a74c399 100644 --- a/js/tests/game-codec.test.mjs +++ b/js/tests/game-codec.test.mjs @@ -144,6 +144,8 @@ function messageFromJson(decoded, schema) { return { type: 'Ping', clientTime: decoded.client_time }; case 'Leave': return { type: 'Leave' }; + case 'Content': + return { type: 'Content', contentVersion: decoded.content_version }; case 'Welcome': return { type: 'Welcome', @@ -224,6 +226,8 @@ function messageToJson(message, kinds) { return { client_time: message.clientTime, type: 'Ping' }; case 'Leave': return { type: 'Leave' }; + case 'Content': + return { content_version: message.contentVersion, type: 'Content' }; case 'Welcome': return { content_version: message.contentVersion, @@ -432,7 +436,7 @@ test('decoders reject malformed input like the Rust reference', () => { assert.throws(() => new WireReader(fromHex('ffffffff10')).varuint(), /VARINT_OVERFLOW/); assert.throws(() => new WireReader(fromHex('02')).bool(), /INVALID/); assert.throws(() => new WireReader(fromHex('02c328')).string(), /INVALID_UTF8/); - assert.throws(() => decodeClientMessage(fromHex('05')), /UNKNOWN_MESSAGE/); + assert.throws(() => decodeClientMessage(fromHex('06')), /UNKNOWN_MESSAGE/); // A delta update for an unknown entity id. const { messages } = fixture('messages.json'); const delta = messages.find((m) => m.name === 'delta'); From 13eac2a831e680f151aa315a412ec4a5e09d6d2d Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 21 Oct 2026 03:28:34 -0500 Subject: [PATCH 13/21] feat(distributed_game): snap_above cuts teleports in corrections `#[correct(ease = MS, snap_above = M)]` (SDK `snapAbove`): when a reconciliation moves an eased field further than M, the whole entity cuts to the rebased state instead of easing. A respawn or other server-side teleport was eased over the ease window, sliding the player across the floor at metres per frame. Generated clients carry the field. [[tasks/gr-p44-mobpit-world]] --- distributed_cli/src/game_client.rs | 16 +++++-- .../fixtures/game-client/sample/schema.ts | 2 +- .../fixtures/game-client/sample/surface.json | 3 +- distributed_game/README.md | 3 +- distributed_game/src/schema.rs | 7 ++- distributed_game/tests/authoring/main.rs | 19 ++++++-- .../tests/fixtures/authoring.json | 3 +- distributed_game_macros/src/lib.rs | 3 +- distributed_game_macros/src/shared.rs | 44 +++++++++++++---- js/README.md | 3 +- js/src/game/prediction.ts | 47 ++++++++++++++----- js/tests/game-replica-prediction.test.mjs | 19 ++++++++ 12 files changed, 137 insertions(+), 32 deletions(-) diff --git a/distributed_cli/src/game_client.rs b/distributed_cli/src/game_client.rs index af0bb32c..d7c9909c 100644 --- a/distributed_cli/src/game_client.rs +++ b/distributed_cli/src/game_client.rs @@ -152,6 +152,8 @@ enum Correction { Ease { #[serde(rename = "durationMs")] duration_ms: u32, + #[serde(rename = "snapAbove", default)] + snap_above: Option, }, Snap, Replace, @@ -1056,9 +1058,17 @@ impl<'a> Gen<'a> { let _ = writeln!(out, "\t{}: {{", key(&kind.name)); for (field, correction) in corrected { let policy = match correction { - Correction::Ease { duration_ms } => { - format!("{{ mode: 'ease', durationMs: {duration_ms} }}") - } + Correction::Ease { + duration_ms, + snap_above: None, + } => format!("{{ mode: 'ease', durationMs: {duration_ms} }}"), + Correction::Ease { + duration_ms, + snap_above: Some(above), + } => format!( + "{{ mode: 'ease', durationMs: {duration_ms}, snapAbove: {} }}", + f32_literal(*above) + ), Correction::Snap => "{ mode: 'snap' }".into(), Correction::Replace => "{ mode: 'replace' }".into(), }; diff --git a/distributed_cli/tests/fixtures/game-client/sample/schema.ts b/distributed_cli/tests/fixtures/game-client/sample/schema.ts index 9cfc8648..9cdb565f 100644 --- a/distributed_cli/tests/fixtures/game-client/sample/schema.ts +++ b/distributed_cli/tests/fixtures/game-client/sample/schema.ts @@ -142,7 +142,7 @@ export const FIELD_TYPES = { export const CORRECTIONS: CorrectionPolicies = { player: { x: { mode: 'ease', durationMs: 150 }, - y: { mode: 'ease', durationMs: 150 }, + y: { mode: 'ease', durationMs: 150, snapAbove: 2.5 }, vx: { mode: 'snap' }, face: { mode: 'replace' }, name: { mode: 'replace' }, diff --git a/distributed_cli/tests/fixtures/game-client/sample/surface.json b/distributed_cli/tests/fixtures/game-client/sample/surface.json index 892008aa..d3d7c3a3 100644 --- a/distributed_cli/tests/fixtures/game-client/sample/surface.json +++ b/distributed_cli/tests/fixtures/game-client/sample/surface.json @@ -259,7 +259,8 @@ { "correction": { "durationMs": 150, - "mode": "ease" + "mode": "ease", + "snapAbove": 2.5 }, "name": "y", "priority": 1.0, diff --git a/distributed_game/README.md b/distributed_game/README.md index 686b9192..8ff7da2c 100644 --- a/distributed_game/README.md +++ b/distributed_game/README.md @@ -399,7 +399,8 @@ against its former hand-written loop. bytes), `#[quant(min, max, bits)]` / `#[quant(centered = R, bits)]` (zero exact) / `#[angle(bits)]` / `#[field_type(EXPR)]` for floats, `#[priority(p)]`, and `#[correct(ease = ms | snap | replace)]` client -defaults. The interest position is the `x` / `y` fields (or +defaults (`ease = ms, snap_above = m`: an error larger than `m` cuts the whole +entity — a respawn or another server-side teleport is a cut, not a slide). The interest position is the `x` / `y` fields (or `#[replicated(x = .., y = ..)]`); a kind without one must be `global`. `out.add(..)` returns a handle for `.global()`, `.local()`, `.priority(p)`. For each client the engine then sends only the difference from what that diff --git a/distributed_game/src/schema.rs b/distributed_game/src/schema.rs index 1c41aeed..86cc28d7 100644 --- a/distributed_game/src/schema.rs +++ b/distributed_game/src/schema.rs @@ -313,7 +313,7 @@ fn steps(bits: u8) -> f64 { /// SDK's `CorrectionPolicy`). Client metadata only: never sent on the wire, /// exported in the [`GameSurface`](crate::surface::GameSurface) so generated /// clients get per-field defaults. -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)] pub enum Correction { /// Adopt the rebased value, easing the visual error out over `duration_ms`. @@ -321,6 +321,11 @@ pub enum Correction { /// Ease duration (ms). #[serde(rename = "durationMs")] duration_ms: u32, + /// An error larger than this cuts instead, every eased field of the + /// entity at once (a teleport — a respawn, a server-side move — is a + /// cut, not a misprediction to slide over). + #[serde(rename = "snapAbove", default, skip_serializing_if = "Option::is_none")] + snap_above: Option, }, /// Adopt the rebased value immediately. Snap, diff --git a/distributed_game/tests/authoring/main.rs b/distributed_game/tests/authoring/main.rs index c4e4c516..ac5e38bd 100644 --- a/distributed_game/tests/authoring/main.rs +++ b/distributed_game/tests/authoring/main.rs @@ -31,7 +31,7 @@ pub struct PlayerView { #[correct(ease = 150)] pub x: f32, #[quant(centered = 20.0, bits = 18)] - #[correct(ease = 150)] + #[correct(ease = 150, snap_above = 2.5)] pub y: f32, #[quant(min = -16.0, max = 16.0, bits = 12)] #[correct(snap)] @@ -371,7 +371,17 @@ fn replicated_kind_def_and_values_share_one_field_order() { assert_eq!(kind.fields[0].ty, FieldType::quant_centered(20.0, 18)); assert_eq!( kind.fields[0].correction, - Some(Correction::Ease { duration_ms: 150 }) + Some(Correction::Ease { + duration_ms: 150, + snap_above: None + }) + ); + assert_eq!( + kind.fields[1].correction, + Some(Correction::Ease { + duration_ms: 150, + snap_above: Some(2.5) + }) ); assert_eq!(kind.fields[2].correction, Some(Correction::Snap)); assert_eq!(kind.fields[3].ty, FieldType::Angle { bits: 8 }); @@ -509,7 +519,10 @@ fn surface_describes_everything_once() { assert_eq!(surface.kinds.len(), 3); assert_eq!( surface.kinds[0].fields[0].correction, - Some(Correction::Ease { duration_ms: 150 }) + Some(Correction::Ease { + duration_ms: 150, + snap_above: None + }) ); let input = surface.input.as_ref().expect("typed input"); assert_eq!(input.name, "SampleInput"); diff --git a/distributed_game/tests/fixtures/authoring.json b/distributed_game/tests/fixtures/authoring.json index 492d1eb2..ff79bed1 100644 --- a/distributed_game/tests/fixtures/authoring.json +++ b/distributed_game/tests/fixtures/authoring.json @@ -400,7 +400,8 @@ { "correction": { "durationMs": 150, - "mode": "ease" + "mode": "ease", + "snapAbove": 2.5 }, "name": "y", "priority": 1.0, diff --git a/distributed_game_macros/src/lib.rs b/distributed_game_macros/src/lib.rs index 9612813d..9779a4d1 100644 --- a/distributed_game_macros/src/lib.rs +++ b/distributed_game_macros/src/lib.rs @@ -21,7 +21,8 @@ //! `FieldType` expression (e.g. a shared `const POS: FieldType`). //! - `#[long]` on a `String` payload field: varuint length instead of the //! 255-byte short string. -//! - Replicated only: `#[priority(P)]`, `#[correct(ease = MS | ease | snap | replace)]`, +//! - Replicated only: `#[priority(P)]`, `#[correct(ease = MS | ease | snap | replace)]` +//! (with `ease`, `snap_above = M`: larger errors snap — a teleport is a cut),, //! `#[from]` / `#[from(path.to.field)]` (with `#[replicated(.., of = Entity)]`). //! - GameInput only: `#[range(min = A, max = B)]` (clamp), `#[buttons(a, b, ..)]` //! (bit flags, unknown bits cleared; also generates `Type::A`, `Type::B` consts). diff --git a/distributed_game_macros/src/shared.rs b/distributed_game_macros/src/shared.rs index 3d21cd2e..0f84bc41 100644 --- a/distributed_game_macros/src/shared.rs +++ b/distributed_game_macros/src/shared.rs @@ -217,35 +217,63 @@ pub(crate) fn parse_field_attrs(field: &Field, rt: &TokenStream) -> syn::Result< return Err(syn::Error::new(span, "duplicate #[correct]")); } let mut policy = None; + let mut ease_ms: Option = None; + let mut snap_above: Option = None; attr.parse_nested_meta(|meta| { - if policy.is_some() { - return Err(meta.error("#[correct(..)] takes one policy")); - } let key = meta .path .get_ident() .map(Ident::to_string) .unwrap_or_default(); + if key == "snap_above" { + if snap_above.is_some() { + return Err(meta.error("duplicate `snap_above`")); + } + snap_above = Some(meta.value()?.parse()?); + return Ok(()); + } + if policy.is_some() { + return Err(meta.error("#[correct(..)] takes one policy")); + } policy = Some(match key.as_str() { "ease" => { - let ms: Expr = if meta.input.peek(syn::Token![=]) { + ease_ms = Some(if meta.input.peek(syn::Token![=]) { meta.value()?.parse()? } else { syn::parse_quote!(100) - }; - quote!(#rt::__private::Correction::Ease { duration_ms: (#ms) as u32 }) + }); + quote!() } "snap" => quote!(#rt::__private::Correction::Snap), "replace" => quote!(#rt::__private::Correction::Replace), _ => return Err(meta.error( - "expected `ease`, `ease = MS`, `snap` or `replace` in #[correct(..)]", + "expected `ease`, `ease = MS`, `snap` or `replace` (with `ease`: `snap_above = M`) in #[correct(..)]", )), }); Ok(()) })?; - let Some(policy) = policy else { + let Some(mut policy) = policy else { return Err(syn::Error::new(span, "#[correct(..)] needs a policy")); }; + match (ease_ms, snap_above) { + (Some(ms), above) => { + let above = match above { + Some(m) => quote!(::core::option::Option::Some((#m) as f32)), + None => quote!(::core::option::Option::None), + }; + policy = quote!(#rt::__private::Correction::Ease { + duration_ms: (#ms) as u32, + snap_above: #above, + }); + } + (None, Some(_)) => { + return Err(syn::Error::new( + span, + "`snap_above` only applies to `ease` in #[correct(..)]", + )) + } + (None, None) => {} + } out.correct = Some((policy, span)); } "range" => { diff --git a/js/README.md b/js/README.md index a2a0dfab..d9a69ecc 100644 --- a/js/README.md +++ b/js/README.md @@ -756,7 +756,8 @@ const game = new GameClient<{ dx: number; dy: number }>({ prediction: { encodeInput: ({ dx, dy }) => new Uint8Array([dx & 0xff, dy & 0xff]), predict: (s, i, dt) => ({ x: s.x + i.dx * 10 * dt / 1000, y: s.y + i.dy * 10 * dt / 1000 }), - corrections: { avatar: { x: { mode: 'ease', durationMs: 120 }, hp: { mode: 'replace' } } } + // `snapAbove`: a larger error (a respawn, a teleport) cuts instead of sliding. + corrections: { avatar: { x: { mode: 'ease', durationMs: 120, snapAbove: 2 }, hp: { mode: 'replace' } } } } }); await game.connect('room-1'); diff --git a/js/src/game/prediction.ts b/js/src/game/prediction.ts index 2b1467b0..575684a7 100644 --- a/js/src/game/prediction.ts +++ b/js/src/game/prediction.ts @@ -18,7 +18,13 @@ import { angleDelta, wrapAngle, type EntityFields } from './replica.js'; import { canonicalizeField, type FieldValue, type GameKindDef } from './schema.js'; export type CorrectionPolicy = - | Readonly<{ mode: 'ease'; durationMs?: number }> + /** + * Ease the visual error out over `durationMs`. An error larger than + * `snapAbove` cuts instead — every eased field of the entity at once (a + * teleport such as a respawn moves x and y together; easing it would + * slide the entity across the floor). + */ + | Readonly<{ mode: 'ease'; durationMs?: number; snapAbove?: number }> | Readonly<{ mode: 'snap' }> | Readonly<{ mode: 'replace' }>; @@ -146,6 +152,7 @@ export class Predictor { const kind = this.#kind; if (predicted === undefined || kind === undefined) return; const epsilon = this.#options.epsilon ?? 1e-6; + const cut = this.#teleported(shown, predicted); for (const field of kind.fields) { const policy = this.#policy(field.name); const from = shown[field.name]; @@ -154,7 +161,7 @@ export class Predictor { const angle = field.type === 'angle'; const value = angle ? -angleDelta(from, to) : from - to; const duration = policy.durationMs ?? 100; - if (Math.abs(value) > epsilon && duration > 0) { + if (Math.abs(value) > epsilon && duration > 0 && !cut) { this.#offsets.set(field.name, { value, start: now, durationMs: duration, angle }); } else { this.#offsets.delete(field.name); @@ -162,6 +169,22 @@ export class Predictor { } } + /** True when an `ease` field with `snapAbove` moved further than that (a teleport). */ + #teleported(shown: EntityFields, next: EntityFields): boolean { + const kind = this.#kind; + if (kind === undefined) return false; + for (const field of kind.fields) { + const policy = this.#policy(field.name); + if (policy.mode !== 'ease' || policy.snapAbove === undefined) continue; + const from = shown[field.name]; + const to = next[field.name]; + if (typeof from !== 'number' || typeof to !== 'number') continue; + const value = field.type === 'angle' ? -angleDelta(from, to) : from - to; + if (Math.abs(value) > policy.snapAbove) return true; + } + return false; + } + /** New session (Welcome): sequence numbers restart, pending inputs are void. */ resetSession(): void { this.#pending = []; @@ -200,6 +223,8 @@ export class Predictor { const errors: Record = {}; const resolved: Record = {}; const epsilon = this.#options.epsilon ?? 1e-6; + // A teleport is a cut, not a misprediction to slide over. + const cut = before !== undefined && this.#teleported(before, rebased); for (const field of this.#kind.fields) { const name = field.name; const policy = this.#policy(name); @@ -213,20 +238,20 @@ export class Predictor { const newValue = rebased[name]; if (typeof oldValue === 'number' && typeof newValue === 'number') { const error = angle ? -angleDelta(oldValue, newValue) : oldValue - newValue; + const shown = before[name]; + const visual = + typeof shown === 'number' + ? angle + ? -angleDelta(shown, newValue) + : shown - newValue + : 0; if (Math.abs(error) > epsilon) { errors[name] = error; - resolved[name] = policy.mode; + resolved[name] = cut && policy.mode === 'ease' ? 'snap' : policy.mode; } if (policy.mode === 'ease') { - const shown = before[name]; - const visual = - typeof shown === 'number' - ? angle - ? -angleDelta(shown, newValue) - : shown - newValue - : 0; const duration = policy.durationMs ?? 100; - if (Math.abs(visual) > epsilon && duration > 0) { + if (Math.abs(visual) > epsilon && duration > 0 && !cut) { this.#offsets.set(name, { value: visual, start: now, durationMs: duration, angle }); } else { this.#offsets.delete(name); diff --git a/js/tests/game-replica-prediction.test.mjs b/js/tests/game-replica-prediction.test.mjs index cd7d23ff..9a152bc1 100644 --- a/js/tests/game-replica-prediction.test.mjs +++ b/js/tests/game-replica-prediction.test.mjs @@ -142,6 +142,25 @@ test('ease blends the divergence out over time while snap jumps', () => { assert.equal(ce.resolved.hp, 'replace'); }); +test('ease with snapAbove: a small error eases, a teleport cuts', () => { + const near = boundPredictor({ mode: 'ease', durationMs: 200, snapAbove: 3 }); + const far = boundPredictor({ mode: 'ease', durationMs: 200, snapAbove: 3 }); + for (const p of [near, far]) { + for (let seq = 1; seq <= 4; seq += 1) p.record(seq, 10, 100); + } + // Shown 4. A wall (rebased 2: error 2) eases; a respawn at -10 (rebased + // -9: error 13) cuts at once. + const cn = near.reconcile(3, { x: 1, y: 0, yaw: 0, hp: 100, name: 'p' }, 1000); + const cf = far.reconcile(3, { x: -10, y: 0, yaw: 0, hp: 100, name: 'p' }, 1000); + assert.equal(cn.resolved.x, 'ease'); + close(near.current(1000).x, 4, 1e-9, 'small: eased from what was shown'); + assert.equal(cf.resolved.x, 'snap'); + close(cf.errors.x, 13, 1e-9); + close(far.current(1000).x, -9, 1e-9, 'teleport: shown at once, no slide'); + assert.equal(far.current(1000).hp, 100); + assert.deepEqual(far.visualError(1000), {}); +}); + test('a new correction while easing starts from the currently displayed value', () => { const p = boundPredictor({ mode: 'ease', durationMs: 100 }); p.record(1, 10, 1000); // predicted x = 10 From b493b10d4736c62d6ce62deefe762171bcc5372b Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 21 Oct 2026 03:28:55 -0500 Subject: [PATCH 14/21] feat(game)!: live content updates in Mob Pit; delete The Freeze An admin rolls a content pack (Classic, Frostfall, Emberfall: mob pace, toughness, tint) out to every live room through the world's content-rollout cell: rooms freeze with the change notes, switch packs and resume; facts carry the new version in their stamp. Old clients (SafeRoom, no roaming) keep their pack, are held at every doorway but the entrance's and reloaded there; a fresh join on old content reloads. Services: `GET /dev/mobpit/content`, `POST /dev/mobpit/rollout` (admin secret, run as the services' admin session). Vite page: admin panel (`?admin=`), `?old=1`, freeze overlay, stale banner, mob tint. SvelteKit: `/api/dev/rollout` (dev sign-in) and the same overlay and panel. The Freeze is deleted with its two-host server, gem-pouch and content-log cells, client, tests and run.sh entry; the common Demo plumbing and the Arena predicted step go with it. README describes the one game. [[tasks/gr-p44-mobpit-world]] --- examples/game/README.md | 262 +++++++---------- examples/game/cells/Cargo.toml | 4 +- examples/game/cells/src/lib.rs | 278 ++++-------------- examples/game/cells/wrangler.jsonc | 17 +- examples/game/client/index.html | 5 +- .../client/src/generated/mobpit/constants.ts | 26 +- .../client/src/generated/mobpit/events.ts | 29 ++ .../client/src/generated/mobpit/surface.json | 105 +++++++ examples/game/client/src/mobpit/client.ts | 129 +++++++- examples/game/client/src/mobpit/main.ts | 79 ++++- examples/game/client/src/mobpit/style.css | 53 ++++ examples/game/client/tests/helpers/mobpit.mjs | 5 +- .../tests/mobpit-content-headless.test.mjs | 136 +++++++++ .../client/tests/prediction-wasm.test.mjs | 4 +- examples/game/client/vite.config.ts | 2 +- examples/game/run.sh | 62 +--- examples/game/server/rooms/src/celld.rs | 3 + examples/game/server/rooms/src/zone.rs | 147 ++++++++- examples/game/server/rooms/tests/rooms.rs | 131 +++++++++ examples/game/server/sim/src/arena.rs | 27 +- examples/game/server/sim/src/lib.rs | 14 +- examples/game/server/src/common/dev.rs | 163 +--------- examples/game/server/src/common/mod.rs | 183 +----------- examples/game/server/src/lib.rs | 1 - examples/game/server/src/main.rs | 4 - examples/game/server/src/mobpit/mod.rs | 16 +- examples/game/server/src/mobpit/routes.rs | 130 +++++++- examples/game/server/src/mobpit/store.rs | 54 ++++ examples/game/server/src/predict_trace.rs | 36 +-- examples/game/server/tests/mobpit_smoke.rs | 4 + .../src/lib/generated/mobpit/constants.ts | 26 +- .../src/lib/generated/mobpit/events.ts | 29 ++ .../src/lib/generated/mobpit/surface.json | 105 +++++++ examples/game/sveltekit/src/lib/pit/loop.ts | 12 +- .../game/sveltekit/src/lib/server/game.ts | 32 ++ .../src/routes/api/dev/rollout/+server.ts | 64 ++++ .../src/routes/api/game/content/+server.ts | 13 + .../sveltekit/src/routes/pit/+page.server.ts | 5 +- .../sveltekit/src/routes/pit/+page.svelte | 98 +++++- examples/game/sveltekit/tests/pit.test.mjs | 17 ++ 40 files changed, 1650 insertions(+), 860 deletions(-) create mode 100644 examples/game/client/tests/mobpit-content-headless.test.mjs create mode 100644 examples/game/sveltekit/src/routes/api/dev/rollout/+server.ts create mode 100644 examples/game/sveltekit/src/routes/api/game/content/+server.ts diff --git a/examples/game/README.md b/examples/game/README.md index 47b0b1fe..c67f77ab 100644 --- a/examples/game/README.md +++ b/examples/game/README.md @@ -1,32 +1,34 @@ -# distributed_game demos - -Small playable demos of the authoritative game runtime -([`distributed_game`](../../distributed_game/README.md)) and its browser SDK -([`@hops-ops/distributed/game`](../../js/README.md#game-zones)), each focused -on one mechanic. **Dev only**: the demo server mints tickets for anonymous -guests and lets any page change the network simulation. - -| Demo | Run | Mechanic it demonstrates | Durable state | +# distributed_game demo: Mob Pit + +One playable game, **Mob Pit**, on the authoritative game runtime +([`distributed_game`](../../distributed_game/README.md)), its browser SDK +([`@hops-ops/distributed/game`](../../js/README.md#game-zones)) and celld. +Every mechanic of the earlier standalone demos (Arena, Endless Grid, Boss +Ripple, Shooting Gallery, The Freeze) now lives in it; the same game runs +behind a Vite page and a SvelteKit app. **Dev only**: the services mint +tickets for anonymous guests and let any page change the network +simulation. + +| Page | Run | What it shows | Durable state | |---|---|---|---| -| **Mob Pit** | `./run.sh mobpit` (`--fleet` for a local multi-node celld) | A near-infinite seeded grid of rooms joined by doorways, **each room a celld Durable Object** (celld places, forwards, hibernates and recovers rooms; the debug overlay shows the node); authoritative melee combat vs. telegraphing mobs; **hoods, boroughs and a city** above the rooms (each tier a celld cell: a seeded boss room per hood, a boss kill raises the hood's tier — tougher rooms — and a saga ripples it up; the borough's weather and darkness lift as its bosses fall, fog thickens toward boss rooms); the **rooms next door** watched as passive observers (their mobs are drawn; a watcher never wakes a sleeping room or keeps one awake); a **network panel** (latency / jitter / loss applied by every room object on celld, client prediction of the Rust step as WASM with reconciliation, a server ghost, an interest ring: what is outside is not replicated); kills are **non-blocking durable facts** settled exactly once in per-player cells; rooms you leave go dormant then asleep (live minimap from the zone status read model) and come back with their mobs | celld: `pit..` rooms (snapshots), `crawler/{player}`, `presence/{player}`, `zone-activity/{room}`; crawler list and room status = read models projected from the cells' events | +| **Mob Pit** | `./run.sh mobpit` (`--fleet` for a local multi-node celld) | A near-infinite seeded grid of rooms joined by doorways, **each room a celld Durable Object** (celld places, forwards, hibernates and recovers rooms; the debug overlay shows the node); authoritative melee combat vs. telegraphing mobs; **hoods, boroughs and a city** above the rooms (each tier a celld cell: a seeded boss room per hood, a boss kill raises the hood's tier — tougher rooms — and a saga ripples it up; the borough's weather and darkness lift as its bosses fall, fog thickens toward boss rooms); the **rooms next door** watched as passive observers (their mobs are drawn; a watcher never wakes a sleeping room or keeps one awake); a **network panel** (latency / jitter / loss applied by every room object on celld, client prediction of the Rust step as WASM with reconciliation, a server ghost, an interest ring: what is outside is not replicated); kills are **non-blocking durable facts** settled exactly once in per-player cells; rooms you leave go dormant then asleep (live minimap from the zone status read model) and come back with their mobs; seeded room layouts (obstacles in the authoritative and predicted step), **jumping**, a **bow** with lag-compensated hits and **first person**; borough **world events** (invasion, frenzy, blessing) on the boroughs' own schedules; **live content updates**: an admin rolls a content pack out to every live room (freeze → apply → resume with change notes; old clients held until the entrance reloads them; facts stamped with the content version) | celld: `pit..` rooms (snapshots), `crawler/{player}`, `presence/{player}`, `zone-activity/{room}`, `hood/…`, `borough/…`, `city/city`, `content-rollout/world`; crawler list, room status and boroughs = read models projected from the cells' events | | **Leaderboard** | `./run.sh leaderboard` | Same setup as Mob Pit: kills/XP sorted-set boards (in-memory or Redis) fed from the projected crawler list with idempotent `max` updates, your rank + neighbours, rebuild from the cells' event history | celld (the Mob Pit cells); boards are a projection (memory or Redis) | -| **The Freeze** | `./run.sh freeze` | World-version barrier (`WorldCoordinator::rollout`) across **two server processes** as an in-world "admin update": freeze → apply → resume with change notes, inputs dropped while frozen, Hard reload vs safe-room gating (`StalePolicy::SafeRoom`, `Zone::is_gate`) of old clients, aborted rollout (`freeze_ready` → `NotAcked`), facts/events stamped with the content version | celld: `gem-pouch/{player}`, `content-log/{zone}` | | **Mob Pit · SvelteKit** | `REDIS_URL=… ./run.sh sveltekit` | The same Mob Pit server behind a SvelteKit app: Auth.js session sign-in, admission tickets minted from the session (`createGameTicketHandler`), the game client and WASM step generated by the Vite plugin (`games`), a runes HUD (`useGame`) next to a plain canvas loop, the settled kills as a business-side read model (Crawler domain events projected by `mobpit-service` into SQL, read through generated `@load @live` GraphQL), and the zone server's Redis XP board through GraphQL board roots, live across processes over the bus. See [SvelteKit](#sveltekit) | celld (the Mob Pit cells) + `mobpit-service`'s SQL read model + the Redis boards | -`./run.sh smoke` runs every demo's smoke tests (and checks the generated -clients are current, and drives the SvelteKit demo headlessly); `./run.sh gen` regenerates the clients; `./run.sh -list` lists demos. +`./run.sh smoke` runs the smoke tests (Rust bots, headless JS, the generated +client checked current, the SvelteKit app driven headlessly); `./run.sh gen` +regenerates the client; `./run.sh list` lists the pages. **How the demos are written.** Each zone describes its world with the `distributed_game` authoring derives: `#[derive(Replicated)]` view structs for the kinds (`mobpit::zone::PlayerView`, `mobpit::zone::MobView`, …), -`#[derive(GameInput)]` for the input frame (`game_sim::mobpit::PitInput`, -`freeze`'s `MoveInput`, …), `#[derive(GameEvent)]` enums for events +`#[derive(GameInput)]` for the input frame (`game_sim::mobpit::PitInput`), +`#[derive(GameEvent)]` enums for events (`mobpit::zone::PitEvent`) and `#[derive(FactCommand)]` on the cell command inputs they emit as facts (`mobpit::crawler::RecordKillInput`). The movement that clients predict is declared once with `#[predict]` in the `game_sim` crate (`server/sim/`, no server dependencies): -`arena_step` (The Freeze) and `pit_step` (Mob Pit); the zones call those functions in `on_tick`. Each demo module +`pit_step`; the rooms call it in `on_tick`. The surface module exports `game_surface()`; `run.sh gen` runs `distributed game-manifest` on it, `distributed game-client` writes `client/src/generated//` (checked in, verified by `run.sh smoke`) and `distributed game-wasm` @@ -41,11 +43,9 @@ No simulation or content code is written twice. Movement is the compiled step; Mob Pit's rooms (biome, danger, mobs) come from the compiled `pit_room` (`createMobpitPures`); static data the pages draw is a generated constant from the Rust values (`PIT_WORLD` — room -size, doorways, pillars, capsule radius —, `WEATHER` names, `ARENA_FLOOR` -for The Freeze, `FREEZE_WORLD`, `PIT_RULES` tier radii, ranged reach, -eye and hip heights, mob heights); The Freeze's content packs are a Rust -record served in its payload encoding and decoded by the generated -`decodePack`. What TypeScript still mirrors is small and listed under +size, doorways, pillars, capsule radius —, `WEATHER` names, `PIT_RULES` +tier radii, ranged reach, eye and hip heights, mob heights, +`CONTENT_PACKS`, `GATE_ROOM`). What TypeScript still mirrors is small and listed under [Remaining client-side mirrors](#remaining-client-side-mirrors). Requirements: Rust (stable) with the `wasm32-unknown-unknown` target @@ -93,52 +93,35 @@ aim (yaw, pitch), the shot id and the view delay (`PitInput`, 11–14 bytes). The SvelteKit page aims with the mouse (top-down), and its "first person" turns the canvas with the aim under pointer lock. -The Freeze: the server prints an **admin secret** (`./run.sh freeze` shows -it; set `DG_DEMO_ADMIN_SECRET` to choose one) — open -`/freeze/?admin=` (add `&old=1` in a second tab for an old client). -`game-demos freeze` hosts the Town Hub (safe-room gate) and Whisper Meadow -and starts a second process of itself for Ember Fields (`beta`, shared -ticket + admin secrets, exits with its parent); portals hand players off -between them (`Redirect` + `/admin/transfer` across processes). Content is -data-driven per version (`freeze::content`: v1 lava pools that burn, v2 -"lava is now ice", v3 back to lava; slime speed/size/color, crystals, gem -value) and served by `GET /dev/freeze/content/{version}` — the Rust `Pack` -record in its payload encoding, decoded by the generated `decodePack`; the -version-independent layout (zones, portals, pools, crystal spots) is the -generated `FREEZE_WORLD` constant. The client derives the scene from *its -own* pack. Packs are served, not compiled into the page, because the demo -is about a client receiving content it was not built with. - -- **Roll out update (Hard)**: every zone on both hosts freezes (SYSTEM - overlay, inputs dropped and counted — client side, plus any in flight on - the server), the "content CDN" stages the pack — an async `apply` - awaited by `WorldCoordinator::rollout_async` while everything stays - frozen (`DG_FREEZE_STAGE_MS`, default 2.5 s) — every zone resumes on the next version with the notes, - and every client reloads (`Kick(RELOAD)` → `reloadContent` → reconnect - with the token). -- **Roll out (Safe room)**: rolling commit. Normal clients patch live - (`client.setContentVersion`); an old client (**O** / `&old=1`) keeps its - old pack in the field — the server already runs the new rules — until it - walks through a portal into the Town Hub, where it gets `RELOAD`. - Every client already *in* the hub when the update lands is at a safe - point and reloads right there (a rolling commit reloads stale clients - inside gate zones); a live-patching client reports its new version on - that reconnect. -- **Ritual in Ember Fields**: that zone's `freeze_ready()` is false; the - next rollout times out (`NotAcked: beta/freeze-ember`) and every zone - resumes on the old version with "update aborted". - -Each zone switch emits a typed `ContentApplied` fact -(`freeze.content_applied`) and a stamped event; gems emit `GemCollected` -facts (`freeze.gem_collected`, naming the gem spot — the gem's value is a -content-pack rule for the handler, not a zone-reported amount). Both hosts -settle them in celld (`run.sh` starts `celld dev`; both processes share it -through `CELLD_URL` / `DISTRIBUTED_INTERNAL_SECRET`): gems in the player's -`gem-pouch/{principal}` cell, content switches in the zone's -`content-log/{zone}` cell. The admin panel lists the last settled facts with -their stamp. Display names live on the primary host: a player joining Ember -Fields directly (`&zone=freeze-ember`) shows as a guest there, names carry -over portals. +**Live content updates** (Mob Pit, from The Freeze): `run.sh mobpit` +prints an admin link (`/mobpit/?admin=`, the services' admin secret +`DG_DEMO_ADMIN_SECRET`, kept in `MOBPIT_DATA_DIR/admin-secret`); add +`&old=1` in another tab for a client built before the update. The admin +panel rolls a content pack (`mobpit_rooms::content_packs`: Classic, +Frostfall — mobs 30 % slower, 20 % tougher, icy —, Emberfall — 25 % +faster, brittle, glowing) out to **every live room**: + +- `POST /dev/mobpit/rollout` (admin secret) starts the rollout on the + world's `content-rollout/world` cell + (`distributed_game::world::content_rollout`) for the services' admin + session — the body names a known pack, never a principal. +- The cell drives itself on its own schedule: `freezing` → (`freezeMs`, + default 3 s) `applied` → (3 s) `running`; each phase is an outward + event. Every room object follows it (`ZONE_CONTENT_ROLLOUT=world`: read + on wake and every second while it ticks): it freezes (the SYSTEM overlay + with the change notes; inputs are held by the SDK), switches to the pack + (`ZoneHost::set_content_version` → `on_content_version_changed`) and + resumes (`Resume` with the version). Facts from then on carry the new + version in their stamp. A room that slept through it catches up on wake + (a Hello on another version waits for the read, so a doorway admits on + the live version). +- The page patches live (`setContentVersion`, which tells the room — + protocol `Content`); an old client keeps its pack and is stale + (`StalePolicy::SafeRoom { roam: false }`): every doorway but the + entrance's stays closed to it (a `stale` notice), and the entrance + (`Zone::is_gate`) reloads it (`Kick(RELOAD)` → `reloadContent` → back + with the new version and the same body). A fresh join on old content is + told to reload. `hard` reloads stale clients at once instead. ## SvelteKit @@ -262,18 +245,17 @@ errors. ## Architecture ``` - browser (Vite dev server :5173) game-demos --dev (:2567) - ┌──────────────────────────────────┐ ┌─────────────────────────────────────┐ - │ src//main.ts Babylon scene │ │ common::run / spawn │ - │ └ src//client.ts │ POST /dev/ticket │ ├ dev::routes /dev/ticket │ - │ GameClient (SDK) │ ───────────────► │ │ guest principal (server-made) │ - │ ├ GameConnection ws ───────┼─ /zones/{zone} ─►│ │ /dev/netsim → set_net_sim │ - │ ├ EntityReplica (interp.) │ ◄─ Welcome/Delta │ └ GameServer::serve_with │ - │ └ Predictor ── mobpit.wasm │ │ ZoneHost 30 Hz │ - │ src/shared/ hud · input · scene │ │ └ game_sim::arena::arena_step │ - │ src/generated/ (typed glue) │ GET /metrics │ GET /metrics (Prometheus) │ - │ └ wasm/.wasm ◄════════════╪══ distributed game-wasm (same Rust, wasm32) ══╝ │ - └──────────────────────────────────┘ └─────────────────────────────────────┘ + browser (Vite :5173 / SvelteKit) game-demos mobpit --dev (:2567) celld (cells worker) + ┌──────────────────────────────────┐ ┌──────────────────────────────┐ ┌────────────────────────────────┐ + │ src/mobpit/main.ts Babylon scene │ │ /dev/ticket guest tickets │ │ PitRoom one Durable Object │ + │ └ src/mobpit/client.ts │───►│ /dev/mobpit/* (rooms, tiers, │ │ per room pit..: │ + │ GameClient (SDK) │ │ boards, content, rollout) │────►│ ZoneHost 30 Hz │ + │ ├ GameConnection ws ────────┼─ /rooms/{room} ─────────────────────►│ └ game_sim::mobpit::pit_step │ + │ ├ EntityReplica (interp.) │ │ /internal/celld-queue/relay │◄────│ crawler · presence · activity │ + │ └ Predictor ── mobpit.wasm │ │ bus → read models, boards │ │ hood · borough · city · rollout│ + │ src/generated/mobpit (typed glue) │ └──────────────────────────────┘ │ outbox Queue → saga + relay │ + │ └ wasm/mobpit.wasm ◄════════════╪══ distributed game-wasm (same Rust, wasm32) └────────────────────────────────┘ + └──────────────────────────────────┘ ``` Prediction is exact because (1) every input is one command frame of exactly @@ -297,10 +279,10 @@ What the TypeScript still restates, and why it stays: - **Zone id formats and defaults** — `pit..` (`parseRoom` / `roomZone` in `mobpit/client.ts`) and each page's default zone - (`pit.0.0`, `freeze-hub`, …). They are addressing, needed synchronously before + (`pit.0.0`). They are addressing, needed synchronously before any module loads; a mismatch fails loudly (the server rejects the zone). -- **Dev / admin HTTP JSON shapes** — `WorldMap`, `WorldStatus`, - `BoardPosition`: debugging routes outside the game surface. +- **Dev / admin HTTP JSON shapes** — `RoomStatus`, `TiersAround`, + `ContentInfo`, `BoardPosition`: debugging routes outside the game surface. - **The predicted arrow hit** (`rayCapsule` over rendered mobs) — cosmetic feedback; the room's rewound `HitQuery` decides. It could become a `#[game_pure]` over the rendered mobs. @@ -317,7 +299,7 @@ examples/game/ Pit's rooms (`PitRoom`, one Durable Object per room) and the demos' celld cells — Crawler + presence + zone activity + dev settings (`ZoneDevObject`: the rooms' NetSim) + hood / borough / city tiers - (Mob Pit), gem pouch + content log (The Freeze), and the + + the world's content rollout (`ContentRolloutObject`), and the `game-demo-outbox` Queue consumer (relay + the tier saga) server/ Rust crate `game_demos`, its own cargo workspace (with sim/, crawler/, rooms/, service/); also the `distributed build|dev` project @@ -332,23 +314,20 @@ examples/game/ boards as GraphQL board roots (@live from bus notices), GraphQL @live (Bearer only), tests/projection.rs (cells → bus → table → live push, board push over bus notices, no mutations, 401) - sim/ crate `game_sim`: the `#[predict]` steps (arena.rs movement + floor, - mobpit.rs (+ `#[game_pure]` pit_room, doorways), + sim/ crate `game_sim`: the `#[predict]` step (arena.rs planar movement, + mobpit.rs `pit_step` + `#[game_pure]` pit_room, doorways, obstacles), grid.rs seeded noise, biomes and props); compiled natively for the server and to wasm32 for the browser - src/main.rs `game-demos ` subcommands (+ hidden `predict-trace`) + src/main.rs `game-demos mobpit|leaderboard` (+ hidden `predict-trace`) src/predict_trace.rs native traces of every step (recorded inputs) and pure function (arguments) - src/common/ shared: Demo trait, run/spawn, dev routes, guests; + src/common/ shared: CLI options, dev ticket wire types, guests; celld.rs (CelldOptions: celld URL + internal secret), relay.rs (authenticated /internal/celld-queue/relay → SqliteBus), zone_status.rs (zone lease/snapshot/activity status read model) src/devcelld.rs `celld dev` harness for tests (own project, port, process tree) src/mobpit/ Mob Pit services (the rooms run on celld): dev room tickets, relay, crawler directory and room status projections, leaderboards - src/freeze/ The Freeze zone, content packs, two-host world + admin routes - tests/ mobpit_smoke.rs (facts, rooms on celld, celld crash, boards, presence), - freeze_smoke.rs (binary content packs), - (Boss Ripple and the multi-host plumbing are gone: Mob Pit's tiers are cells) + tests/ mobpit_smoke.rs (facts, rooms on celld, celld crash, boards, presence) sveltekit/ SvelteKit app over the Mob Pit server + mobpit-service (see SvelteKit above) distributed.config.js game surface (`games`) + GraphQL client (`clients`) for the Vite plugin distributed.clients.json the `$distributed` client of `mobpit_service` (lifecycle-generated) @@ -363,19 +342,17 @@ examples/game/ src/routes/pit/ load (zone) + page (useGame HUD, settled panel) tests/pit.test.mjs headless Playwright against `node build` + game-demos + mobpit-service client/ Vite + TypeScript + Babylon.js 9.30 (multi-page) - index.html, /index.html - src/generated// generated by `run.sh gen` (do not edit): schema/input/events/client.ts, - predict.ts, pures.ts (mobpit), constants.ts, surface.json, - wasm/.wasm (gitignored build output); - `mobpit` also serves the leaderboard page + index.html, mobpit/index.html, leaderboard/index.html + src/generated/mobpit/ generated by `run.sh gen` (do not edit): schema/input/events/client.ts, + predict.ts, pures.ts, constants.ts, surface.json, + wasm/mobpit.wasm (gitignored build output); also the leaderboard page's src/shared/ hud.ts, input.ts, scene.ts, dev-api.ts, latest.ts, style.css src/mobpit/ client.ts (headless + /dev/mobpit API), main.ts (Babylon), style.css src/leaderboard/ main.ts (DOM page over the observer connection), style.css - src/freeze/ client.ts (content packs, live patch / reload, admin API), main.ts tests/ prediction-wasm (native ↔ WASM exact match of steps + pure functions, cost), mobpit-headless, mobpit-network-headless (NetSim on celld rooms, exact prediction, interest), mobpit-restarts-headless, leaderboard-headless, - mobpit-neighbours-headless (passive watchers), mobpit-ranged-headless (lag-compensated arrows), mobpit-events-headless, freeze-headless, + mobpit-neighbours-headless (passive watchers), mobpit-ranged-headless (lag-compensated arrows), mobpit-events-headless, mobpit-content-headless (live content rollout), mobpit-bosses-headless (hood → borough → city) (+ helpers/demo-server.mjs, helpers/mobpit.mjs, helpers/celld.mjs for a per-test `celld dev`) @@ -602,7 +579,7 @@ fleet test) kills the owner node of a room with 200 facts in flight and counts exactly 200 in the cell. **celld.** Every demo with durable state runs on celld: -`run.sh mobpit|leaderboard|freeze|sveltekit|smoke` need the celld +`run.sh mobpit|leaderboard|sveltekit|smoke` need the celld version pinned in the repository's `.celld-version` (`CELLD_BIN`, default `celld` on PATH; `scripts/celld.sh check` verifies it and prints the install command) and `worker-build` (`cargo install worker-build --locked`) for @@ -639,59 +616,46 @@ are available. map stays empty while the entrance sleeps). Dev routes (`--dev` only, none takes an identity or a reward): -`POST /dev/ticket`, `GET /dev/mobpit/rooms` (room status), `GET -/dev/mobpit/stats`, `GET /dev/mobpit/crawlers` (every crawler in the -directory, eventually consistent), `GET /dev/mobpit/presence?principal=` -(the presence cell), `GET /dev/mobpit/leaderboard[?member=]`, `POST -/dev/mobpit/leaderboard/rebuild`. On celld, `GET /rooms/{room}/info` is the +`POST /dev/ticket`, `POST /dev/mobpit/start`, `GET /dev/mobpit/rooms` +(room status), `GET /dev/mobpit/stats`, `GET /dev/mobpit/crawlers` (every +crawler in the directory, eventually consistent), `GET +/dev/mobpit/presence?principal=` (the presence cell), `GET +/dev/mobpit/leaderboard[?member=]`, `POST /dev/mobpit/leaderboard/rebuild`, +`GET /dev/mobpit/tiers?room=`, `GET|POST /dev/netsim`, `GET +/dev/mobpit/content` and `POST /dev/mobpit/rollout` (admin secret). On celld, `GET /rooms/{room}/info` is the room object's debug JSON (node, isolate, activation, wakes, tick timing). -## Adding a demo - -1. **Server** — `server/src//mod.rs`: the zone plus a unit struct - implementing `common::Demo` (`NAME`, `DEFAULT_ZONE`, `hosts_zone`, - `server(config, guests)`); add `pub mod ;` to `lib.rs` and one - variant to `DemoCommand` in `main.rs`. You get `/dev/ticket`, - `/dev/netsim`, `/metrics`, `--port/--dev/--secret`, graceful shutdown. - - Display names: `guests.name(&ctx.player(p)?.principal)` in `on_join`. - - Kinds: `#[derive(Replicated)]` views, `schema![..]` in `Zone::schema`, - `out.add(id, &view)` in `replicate`. Input: `#[derive(GameInput)]`, - `decode_input` = `MyInput::from_payload(bytes)`. Events: a - `#[derive(GameEvent)]` enum sent with `ctx.send` / `ctx.broadcast`. - Facts: the cell command input with `#[derive(FactCommand)]`. - - Predicted movement: declare the step with `#[predict(Step, kind = "…")]` - in `server/sim/` (with the input type and everything the step reads; - no server-only dependencies), call it from `on_tick`, add - `.predict::()` to the surface and a stream for it in - `src/predict_trace.rs` (the exact-match test fails until you do). - - Export `pub fn game_surface() -> GameSurface` from the module and add - the demo to `SURFACES` in `run.sh`; `./run.sh gen` writes - `client/src/generated//` and compiles the step. - - Signed quantities that must rest at zero: `#[quant(centered = R, bits = N)]`. - - State shared by the zones and demo-specific dev routes (a durable - store, a leaderboard): build both in `common::spawn_with` / - `common::run_with` (see `mobpit::spawn` / `mobpit::run`). -2. **Smoke test** — `server/tests/_smoke.rs`: `common::spawn::` - on `127.0.0.1:0` with `DemoOptions::dev(secret)` and - `distributed_game::bots::run_bots` (custom `encoder` for your input). -3. **Client** — `client//index.html` + `src//main.ts`, add the - name to `DEMOS` in `vite.config.ts` and a link in `index.html`. Keep - networking in `src//client.ts` (no Babylon) so Node can test it: - a thin wrapper over the generated `createClient` (URLs, tickets; - it predicts with the compiled step — set its context if it has one, and - await `prediction.ready` with the first hello). Reuse `DevApi` + `zoneUrl` (tickets), `Hud` (status/RTT/ - jitter/tick/delta B/s/entities + NetSim sliders + `row`/`toggle`), - `InputSampler`, `FixedRate`, `cameraRelative`, `toAxis`, - `createStage`/`toWorld`/`material`/`createRing`/`LabelLayer`, `latest` - (latest value of a typed event, e.g. the hello). -4. **Headless test** — `client/tests/-headless.test.mjs` with - `startDemo('')` from `tests/helpers/demo-server.mjs`. -5. Register the demo in `run.sh` (`DEMOS=(…)`) and in the table above. +## Adding a mechanic + +Mob Pit is the one game; a new mechanic goes into it, not into a new demo. + +1. **Rules the client must agree with** (movement, room contents) go into + `server/sim/` (`game_sim`, wasm32-portable, no server dependencies): the + predicted step (`#[predict]`) or a `#[game_pure]` function, with a trace + in `src/predict_trace.rs` (the exact-match test fails until you add one). +2. **Authority** goes into the room (`server/rooms/src/zone.rs`): input + fields in `PitInput`, events in `PitEvent`, replicated fields in the + view structs, facts as the cell command inputs (`#[derive(FactCommand)]`). + Anything above one room is a cell (`server/tiers/`, mounted in + `cells/`), read off the tick by `CelldZone::refresh`. +3. `./run.sh gen` regenerates `client/src/generated/mobpit/`; the SvelteKit + copy is generated by its Vite plugin. +4. Both pages get the feature (`client/src/mobpit/`, + `sveltekit/src/routes/pit/` + `src/lib/pit/loop.ts`), networking in + `client/src/mobpit/client.ts` so Node can test it. +5. Tests: `server/rooms/tests/rooms.rs` (the room on the reference host), a + headless `client/tests/mobpit--headless.test.mjs` on its own + `celld dev` (`startPit` from `tests/helpers/mobpit.mjs`), and a step in + `sveltekit/tests/pit.test.mjs`. ## Security notes (why this is dev only) -- `--dev` (or `DG_DEMO_DEV=1`) mounts `/dev/*`; without it only `/zones` and - `/metrics` exist. `run.sh` binds `127.0.0.1` and always sets it. +- `--dev` (or `DG_DEMO_DEV=1`) mounts `/dev/*`; without it only the + internal relay route exists. `run.sh` binds `127.0.0.1` and always sets it. +- `/dev/mobpit/rollout` needs the admin secret (`x-game-admin-secret`); the + services then run the rollout cell's command for their own admin session. + The SvelteKit app's `/api/dev/rollout` (dev sign-in only) builds the admin + session from the signed-in user; in the dev demo every user is an admin. - `/dev/ticket` never accepts an identity: the principal is generated by the server (`guest-`); the caller may pass a display name (sanitized, ≤ 16 chars) and a server-signed guest token from an earlier response to @@ -701,7 +665,7 @@ room object's debug JSON (node, isolate, activation, wakes, tick timing). is set (needed to point `game-bots` at a running demo). `run.sh` sets it from `.data//dev-ticket-secret` (Mob Pit: `MOBPIT_DATA_DIR`), kept across runs so a returning browser's guest token still verifies. -- The SvelteKit demo uses no `/dev/*` route: its tickets are minted by the +- The SvelteKit demo uses no services `/dev/*` route: its tickets are minted by the SvelteKit server from the Auth.js session, and its read model is `mobpit-service`, so `run.sh sveltekit` and the smoke test start the zone server without `--dev`. It is still dev only: `GAME_DEV_AUTH=1` lets diff --git a/examples/game/cells/Cargo.toml b/examples/game/cells/Cargo.toml index 67a30d97..992dd76a 100644 --- a/examples/game/cells/Cargo.toml +++ b/examples/game/cells/Cargo.toml @@ -4,7 +4,7 @@ # per-player presence # (with the outbox Queue consumer that relays Crawler domain events to the # zone server and runs the world tier saga), and the hood / borough / city -# tier cells. +# tier cells and the world's content rollout. # Own workspace (cdylib for wasm32-unknown-unknown); built with # `worker-build --release` by `examples/game/run.sh`. [workspace] @@ -16,7 +16,7 @@ name = "game-demo-cells" version = "0.1.0" edition = "2021" publish = false -description = "Game demo cells for celld: Mob Pit, zone leases and zone state (dev demo)" +description = "Mob Pit on celld: rooms, crawler, presence, tier and content cells (dev demo)" [lib] crate-type = ["cdylib"] diff --git a/examples/game/cells/src/lib.rs b/examples/game/cells/src/lib.rs index 02c0bb3a..5cb7e426 100644 --- a/examples/game/cells/src/lib.rs +++ b/examples/game/cells/src/lib.rs @@ -18,8 +18,11 @@ //! the saga that ripples hood events into the borough and borough events //! into the city (idempotent commands, ids derived from the event ids). //! -//! The Freeze's cells are fed by its zone server's -//! `distributed_game::celld::CelldFactSink` over the internal HTTP path. +//! - `content-rollout/world` (`ContentRolloutObject`): the world's live +//! content (`distributed_game::world::content_rollout`). The services' +//! admin route starts a rollout (an `admin:*` session it built after +//! checking the admin secret); the cell freezes, applies and resumes on +//! its own schedule and every room follows it (`ZONE_CONTENT_ROLLOUT`). //! //! - `crawler/{principal}` (`CrawlerCell`): the `mobpit_crawler::Crawler` //! aggregate with its `#[command]` `crawler.record_kill` (XP decided here @@ -30,11 +33,6 @@ //! facts (`zone.*`). A fact emitted before the one that set the current //! entry (`FactStamp::emission_order`) is ignored, so a late "left pit-1" //! cannot mark a player who already joined pit-2 offline. -//! - `gem-pouch/{principal}` (`GemPouchCell`): The Freeze's -//! `freeze.gem_collected` facts (which gem spot, at which content -//! version; the zone reports no amount). -//! - `content-log/{zone}` (`ContentLogCell`): The Freeze's -//! `freeze.content_applied` zone facts (every content switch of a zone). //! //! Every route requires `DISTRIBUTED_INTERNAL_SECRET`. Mob Pit commands also //! require the trusted fact session (principal + `FactStamp` headers, @@ -59,10 +57,13 @@ use distributed::microsvc::{ }; use distributed::{sourced, Aggregate, Entity}; use distributed_game::cell::{fact_session_from_headers, session_stamp}; -use distributed_game::celld_zone::{forward_to_zone, ZoneActivityCell, ZoneDevSettings}; +use distributed_game::celld_zone::{ + forward_to_zone, ContentRolloutCell, ZoneActivityCell, ZoneDevSettings, +}; use distributed_game::lifecycle::{ Lifecycle, CONNECTION_LOST, PLAYER_DISCONNECTED, PLAYER_JOINED, PLAYER_LEFT, PLAYER_RECONNECTED, }; +use distributed_game::world::content_rollout::{CONTENT_ADMIN_ROLE, CONTENT_ROLLOUT_KIND}; use distributed_game::world::zone_activity::ZONE_ACTIVITY_KIND; use distributed_game::FactStamp; use mobpit_crawler::{ @@ -236,220 +237,6 @@ async fn handle_lifecycle( .succeeded(PresenceApplied { applied: newer }) } -// ---------------------------------------------------------------- The Freeze - -/// `freeze.gem_collected`. -pub const GEM_COLLECTED: &str = "freeze.gem_collected"; -/// `freeze.content_applied`. -pub const CONTENT_APPLIED: &str = "freeze.content_applied"; - -/// One collected gem. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Gem { - pub zone: String, - pub spot: u32, - pub content_version: String, -} - -#[derive(Clone, Default, Serialize, Deserialize, distributed::Snapshot)] -pub struct GemPouch { - entity: Entity, - gems: u32, - last: Option, -} - -#[sourced(entity, aggregate_type = "GemPouch", events = "GemPouchEvent")] -impl GemPouch { - #[event("freeze.gem_recorded", version = 1)] - fn recorded(&mut self, principal: String, gem: Gem) { - if self.entity.id().is_empty() { - self.entity.set_id(principal); - } - self.gems += 1; - self.last = Some(gem); - } -} - -#[derive(Debug, Deserialize, distributed::CommandInput)] -pub struct GemInput { - pub spot: u32, -} - -#[derive(Debug, Serialize, distributed::CommandOutput)] -pub struct Counted { - pub count: u32, -} - -struct CollectGem; -impl PortableCommand for CollectGem -where - D: CausalRouteDependencies + Send + Sync + 'static, -{ - fn install(self, routes: Routes) -> Routes { - routes - .typed_command(typed_command::>(GEM_COLLECTED)) - .handle(handle_gem) - } -} - -async fn handle_gem( - ctx: &CausalCommandContext<'_, GemPouch>, - input: GemInput, -) -> std::result::Result>, HandlerError> { - let principal = ctx.user_id()?.to_string(); - let stamp = - session_stamp(ctx.session()).ok_or_else(|| rejected("gem facts need a fact stamp"))?; - let repo = ctx.repo(); - let mut pouch = match repo.get(&principal).await? { - Some(pouch) => pouch, - None => repo.create(), - }; - pouch - .recorded( - principal, - Gem { - zone: stamp.zone_id, - spot: input.spot, - content_version: stamp.content_version, - }, - ) - .map_err(rejected)?; - let count = pouch.gems; - repo.commit(pouch)?.succeeded(Counted { count }) -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct ContentSwitch { - pub from: String, - pub to: String, - pub tick: u64, -} - -#[derive(Clone, Default, Serialize, Deserialize, distributed::Snapshot)] -pub struct ContentLog { - entity: Entity, - switches: Vec, -} - -#[sourced(entity, aggregate_type = "ContentLog", events = "ContentLogEvent")] -impl ContentLog { - #[event("freeze.content_recorded", version = 1)] - fn recorded(&mut self, zone: String, switch: ContentSwitch) { - if self.entity.id().is_empty() { - self.entity.set_id(zone); - } - self.switches.push(switch); - } -} - -#[derive(Debug, Deserialize, distributed::CommandInput)] -pub struct ContentInput { - pub from: String, - pub to: String, -} - -struct ApplyContent; -impl PortableCommand for ApplyContent -where - D: CausalRouteDependencies + Send + Sync + 'static, -{ - fn install(self, routes: Routes) -> Routes { - routes - .typed_command(typed_command::>( - CONTENT_APPLIED, - )) - .handle(handle_content) - } -} - -async fn handle_content( - ctx: &CausalCommandContext<'_, ContentLog>, - input: ContentInput, -) -> std::result::Result>, HandlerError> { - let stamp = - session_stamp(ctx.session()).ok_or_else(|| rejected("content facts need a fact stamp"))?; - let repo = ctx.repo(); - // The zone's own log: the shard is the stamped zone. - let mut log = match repo.get(&stamp.zone_id).await? { - Some(log) => log, - None => repo.create(), - }; - log.recorded( - stamp.zone_id.clone(), - ContentSwitch { - from: input.from, - to: input.to, - tick: stamp.tick, - }, - ) - .map_err(rejected)?; - let count = log.switches.len() as u32; - repo.commit(log)?.succeeded(Counted { count }) -} - -async fn read_pouch(cell: &AggregateCell) -> std::result::Result { - let pouch = cell.load().await.map_err(|error| error.to_string())?; - Ok(json!({ - "gems": pouch.as_ref().map_or(0, |p| p.gems), - "last": pouch.and_then(|p| p.last), - })) -} - -async fn read_content_log(cell: &AggregateCell) -> std::result::Result { - let log = cell.load().await.map_err(|error| error.to_string())?; - Ok(json!({ "switches": log.map(|l| l.switches).unwrap_or_default() })) -} - -#[durable_object] -pub struct GemPouchCell { - cell: AggregateCell, - env: Env, -} - -impl DurableObject for GemPouchCell { - fn new(state: State, env: Env) -> Self { - console_error_panic_hook::set_once(); - let cell = AggregateCell::::from_state_with_snapshots(state, 50) - .expect("gem pouch cell identity") - .mount(CollectGem) - .with_celld_outbox(outbox(&env)); - Self { cell, env } - } - - async fn fetch(&self, req: Request) -> Result { - route(&self.env, &self.cell, req, read_pouch, fact_session).await - } - - async fn alarm(&self) -> Result { - alarm(&self.env, &self.cell).await - } -} - -#[durable_object] -pub struct ContentLogCell { - cell: AggregateCell, - env: Env, -} - -impl DurableObject for ContentLogCell { - fn new(state: State, env: Env) -> Self { - console_error_panic_hook::set_once(); - let cell = AggregateCell::::from_state_with_snapshots(state, 50) - .expect("content log cell identity") - .mount(ApplyContent) - .with_celld_outbox(outbox(&env)); - Self { cell, env } - } - - async fn fetch(&self, req: Request) -> Result { - route(&self.env, &self.cell, req, read_content_log, fact_session).await - } - - async fn alarm(&self) -> Result { - alarm(&self.env, &self.cell).await - } -} - // ---------------------------------------------------------------- Hosting /// Mob Pit cells run only facts: the principal and the stamp, from the @@ -825,6 +612,50 @@ async fn saga(env: &Env, envelope: &CelldQueueEnvelope) -> std::result::Result<( } } +// ---------------------------------------------------------------- Content + +/// Principal prefix of the services' admin sessions. +pub const ADMIN_PREFIX: &str = "admin:"; + +/// A rollout starts only for an admin session the services built after +/// checking the admin's credential (an `admin:*` principal over the +/// internal path); it gets the content-admin role here. The rollout's own +/// schedule needs no session. +fn admin_session(header: &dyn Fn(&str) -> Option) -> Option { + let principal = header(distributed::USER_ID_KEY)?; + principal.starts_with(ADMIN_PREFIX).then(|| { + let mut session = Session::new(); + session.set(distributed::USER_ID_KEY, principal); + session.set(distributed::ROLE_KEY, CONTENT_ADMIN_ROLE); + session + }) +} + +/// The world's live content (`content-rollout/world`): every room follows +/// it (`ZONE_CONTENT_ROLLOUT`). +#[durable_object] +pub struct ContentRolloutObject { + cell: ContentRolloutCell, +} + +impl DurableObject for ContentRolloutObject { + fn new(state: State, env: Env) -> Self { + console_error_panic_hook::set_once(); + let outbox = outbox(&env); + Self { + cell: ContentRolloutCell::new(state, env, outbox, admin_session), + } + } + + async fn fetch(&self, req: Request) -> Result { + self.cell.fetch(req).await + } + + async fn alarm(&self) -> Result { + self.cell.alarm().await + } +} + /// The rooms' dev settings (`zone-dev/{shard}`: the network simulation the /// rooms apply, written by the services' `/dev/netsim`). #[durable_object] @@ -890,13 +721,12 @@ async fn main(req: Request, env: Env, _ctx: Context) -> Result { let binding = match parts.first().copied() { Some(CRAWLER_KIND) => "CRAWLER", Some(PRESENCE_KIND) => "PRESENCE", - Some("gem-pouch") => "GEM_POUCH", - Some("content-log") => "CONTENT_LOG", Some(ZONE_ACTIVITY_KIND) => "ZONE_ACTIVITY", Some(distributed_game::netsim::ZONE_DEV_KIND) => "ZONE_DEV", Some(HOOD_KIND) => "HOOD", Some(BOROUGH_KIND) => "BOROUGH", Some(CITY_KIND) => "CITY", + Some(CONTENT_ROLLOUT_KIND) => "CONTENT_ROLLOUT", _ => return json_status(json!({ "error": "unknown cell kind" }), 404), }; let Some(shard) = parts.get(1) else { diff --git a/examples/game/cells/wrangler.jsonc b/examples/game/cells/wrangler.jsonc index 054132a1..120b6201 100644 --- a/examples/game/cells/wrangler.jsonc +++ b/examples/game/cells/wrangler.jsonc @@ -11,25 +11,26 @@ "bindings": [ { "name": "CRAWLER", "class_name": "CrawlerCell" }, { "name": "PRESENCE", "class_name": "PresenceCell" }, - { "name": "GEM_POUCH", "class_name": "GemPouchCell" }, - { "name": "CONTENT_LOG", "class_name": "ContentLogCell" }, { "name": "PIT_ROOM", "class_name": "PitRoom" }, { "name": "ZONE_ACTIVITY", "class_name": "ZoneActivityObject" }, { "name": "ZONE_DEV", "class_name": "ZoneDevObject" }, { "name": "HOOD", "class_name": "HoodCell" }, { "name": "BOROUGH", "class_name": "BoroughCell" }, - { "name": "CITY", "class_name": "CityCell" } + { "name": "CITY", "class_name": "CityCell" }, + { "name": "CONTENT_ROLLOUT", "class_name": "ContentRolloutObject" } ] }, - // v2's ZoneLeaseCell / ZoneStateCell (the deleted multi-host demos) are - // no longer exported; celld has no `deleted_classes` migration key. + // v2's ZoneLeaseCell / ZoneStateCell and v3's GemPouchCell / + // ContentLogCell (the deleted multi-host demos and The Freeze) are no + // longer exported; celld has no `deleted_classes` migration key. "migrations": [ { "tag": "v1", "new_sqlite_classes": ["CrawlerCell", "PresenceCell"] }, { "tag": "v2", "new_sqlite_classes": ["ZoneLeaseCell", "ZoneStateCell"] }, { "tag": "v3", "new_sqlite_classes": ["GemPouchCell", "ContentLogCell"] }, { "tag": "v4", "new_sqlite_classes": ["PitRoom", "ZoneActivityObject"] }, { "tag": "v5", "new_sqlite_classes": ["ZoneDevObject"] }, - { "tag": "v6", "new_sqlite_classes": ["HoodCell", "BoroughCell", "CityCell"] } + { "tag": "v6", "new_sqlite_classes": ["HoodCell", "BoroughCell", "CityCell"] }, + { "tag": "v7", "new_sqlite_classes": ["ContentRolloutObject"] } ], "queues": { "producers": [ @@ -57,6 +58,8 @@ "ZONE_FACT_SERVICE": "mobpit-rooms", // Dev demo: rooms apply the network simulation of the HUD sliders // (`zone-dev/netsim`, written by the services' `/dev/netsim`). - "ZONE_DEV_SETTINGS": "1" + "ZONE_DEV_SETTINGS": "1", + // Live content updates: rooms follow `content-rollout/world`. + "ZONE_CONTENT_ROLLOUT": "world" } } diff --git a/examples/game/client/index.html b/examples/game/client/index.html index 2b5844c3..71357ad1 100644 --- a/examples/game/client/index.html +++ b/examples/game/client/index.html @@ -9,11 +9,10 @@

    distributed_game demos

    -

    Each demo runs its own server: examples/game/run.sh <demo>. The page for the running demo works; the others need their server.

    +

    One game, Mob Pit, on celld: examples/game/run.sh mobpit (the leaderboard page runs with it).

      -
    • Mob Pit — authoritative combat, non-blocking durable facts into per-player celld cells, slow storage, crash recovery.
    • +
    • Mob Pit — a world of rooms on celld: authoritative melee and ranged combat with prediction and lag compensation, rooms you see into, hoods › boroughs › city with bosses, weather and world events, live content updates, durable facts into per-player cells.
    • Leaderboard — kills/XP sorted-set boards projected from the cells, pushed live, rebuilt from events (runs with Mob Pit).
    • -
    • The Freeze — world-version barrier across two server processes as a story beat: hard reload, safe-room gating of old clients, aborted rollout.
    diff --git a/examples/game/client/src/generated/mobpit/constants.ts b/examples/game/client/src/generated/mobpit/constants.ts index 0d224eb4..9ce9e4dd 100644 --- a/examples/game/client/src/generated/mobpit/constants.ts +++ b/examples/game/client/src/generated/mobpit/constants.ts @@ -1,7 +1,31 @@ // @generated by `distributed game-client` from game surface `mobpit` (schema v3, protocol v1). // Constants: static data defined once in Rust (`GameSurface::constant`). // Do not edit: change the Rust definitions and regenerate. -import type { PitRules, PitWorld } from './events.ts'; +import type { ContentPack, PitRules, PitWorld } from './events.ts'; + +/** `content_packs`, as the client decodes it. */ +export const CONTENT_PACKS: readonly ContentPack[] = [ + { version: '', name: 'Classic', mobPace: 1, mobHpPct: 100, tint: 0, notes: '' }, + { + version: 'frostfall', + name: 'Frostfall', + mobPace: 0.699999988079071, + mobHpPct: 120, + tint: 9425919, + notes: 'Frostfall: the mobs are frozen stiff (30 % slower) but tougher (+20 % HP).' + }, + { + version: 'emberfall', + name: 'Emberfall', + mobPace: 1.25, + mobHpPct: 85, + tint: 16742970, + notes: 'Emberfall: the mobs burn hot (25 % faster) and brittle (-15 % HP).' + } +]; + +/** `gate_room`, as the client decodes it. */ +export const GATE_ROOM: string = 'pit.0.0'; /** `patterns`, as the client decodes it. */ export const PATTERNS: readonly string[] = ['pillars', 'colonnade', 'ring', 'rubble', 'hall', 'grove']; diff --git a/examples/game/client/src/generated/mobpit/events.ts b/examples/game/client/src/generated/mobpit/events.ts index d42a4f85..900ad093 100644 --- a/examples/game/client/src/generated/mobpit/events.ts +++ b/examples/game/client/src/generated/mobpit/events.ts @@ -3,6 +3,16 @@ // Do not edit: change the Rust definitions and regenerate. import { decodeGameEvent, decodePayload, decodeValue, encodePayload, encodeValue, onGameEvent, type DecodedGameEvent, type GameEventCodecs, type GameEventSource, type PayloadLayout, type WireType } from '@hops-ops/distributed/game'; +/** Record `ContentPack` (`#[derive(GameRecord)]`). */ +export interface ContentPack { + readonly version: string; + readonly name: string; + readonly mobPace: number; + readonly mobHpPct: number; + readonly tint: number; + readonly notes: string; +} + /** Record `MobTier` (`#[derive(GameRecord)]`). */ export interface MobTier { readonly tier: number; @@ -288,6 +298,17 @@ const SHOT_LAYOUT: PayloadLayout = [ ['z', { type: 'f32' }], ]; +/** Event `stale` (id 10). */ +export interface StaleEvent { + readonly gate: string; + readonly version: string; +} + +const STALE_LAYOUT: PayloadLayout = [ + ['gate', { type: 'short_str' }], + ['version', { type: 'short_str' }], +]; + /** Message `loaded` (id 1). */ export interface LoadedMessage { readonly principal: string; @@ -323,6 +344,7 @@ export const EVENT = { world: 7, bossDown: 8, shot: 9, + stale: 10, } as const; /** Event payload types by name. */ @@ -335,6 +357,7 @@ export interface Events { world: WorldEvent; bossDown: BossDownEvent; shot: ShotEvent; + stale: StaleEvent; } /** Codecs of every event. */ @@ -387,6 +410,12 @@ export const EVENTS: GameEventCodecs = { encode: (value) => encodePayload(SHOT_LAYOUT, value), decode: (payload) => decodePayload(SHOT_LAYOUT, payload) as unknown as ShotEvent }, + stale: { + id: 10, + name: 'stale', + encode: (value) => encodePayload(STALE_LAYOUT, value), + decode: (payload) => decodePayload(STALE_LAYOUT, payload) as unknown as StaleEvent + }, }; /** A decoded event, discriminated by `name`. */ diff --git a/examples/game/client/src/generated/mobpit/surface.json b/examples/game/client/src/generated/mobpit/surface.json index 19f49d99..7eb9583d 100644 --- a/examples/game/client/src/generated/mobpit/surface.json +++ b/examples/game/client/src/generated/mobpit/surface.json @@ -1,5 +1,48 @@ { "constants": [ + { + "name": "content_packs", + "type": { + "item": { + "name": "ContentPack", + "type": "record" + }, + "type": "list" + }, + "value": [ + { + "mob_hp_pct": 100, + "mob_pace": 1.0, + "name": "Classic", + "notes": "", + "tint": 0, + "version": "" + }, + { + "mob_hp_pct": 120, + "mob_pace": 0.699999988079071, + "name": "Frostfall", + "notes": "Frostfall: the mobs are frozen stiff (30 % slower) but tougher (+20 % HP).", + "tint": 9425919, + "version": "frostfall" + }, + { + "mob_hp_pct": 85, + "mob_pace": 1.25, + "name": "Emberfall", + "notes": "Emberfall: the mobs burn hot (25 % faster) and brittle (-15 % HP).", + "tint": 16742970, + "version": "emberfall" + } + ] + }, + { + "name": "gate_room", + "type": { + "type": "short_str" + }, + "value": "pit.0.0" + }, { "name": "patterns", "type": { @@ -486,6 +529,27 @@ ], "kind": "fields" } + }, + { + "id": 10, + "name": "stale", + "payload": { + "fields": [ + { + "name": "gate", + "type": { + "type": "short_str" + } + }, + { + "name": "version", + "type": { + "type": "short_str" + } + } + ], + "kind": "fields" + } } ], "input": { @@ -977,6 +1041,47 @@ } ], "records": [ + { + "fields": [ + { + "name": "version", + "type": { + "type": "short_str" + } + }, + { + "name": "name", + "type": { + "type": "short_str" + } + }, + { + "name": "mob_pace", + "type": { + "type": "f32" + } + }, + { + "name": "mob_hp_pct", + "type": { + "type": "u16" + } + }, + { + "name": "tint", + "type": { + "type": "u32" + } + }, + { + "name": "notes", + "type": { + "type": "short_str" + } + } + ], + "name": "ContentPack" + }, { "fields": [ { diff --git a/examples/game/client/src/mobpit/client.ts b/examples/game/client/src/mobpit/client.ts index a5566043..6efcdd51 100644 --- a/examples/game/client/src/mobpit/client.ts +++ b/examples/game/client/src/mobpit/client.ts @@ -22,11 +22,28 @@ * none (a sleeping room answers `ASLEEP`; it is tried again every few * seconds and shows nothing meanwhile). * + * Live content updates: the page starts on the world's live content + * version (`/dev/mobpit/content`). An admin rollout freezes every room + * (`Freeze`, inputs held; the change notes on screen), switches them to the + * new pack and resumes them (`Resume` with the version). A normal client + * patches live (`setContentVersion`); a pinned one (`pinContent`, the page's + * `?old=1`) stays on its pack and is `stale`: the room keeps it from every + * doorway but the entrance (`stale` event) and reloads it there + * (`Kick(RELOAD)` → `reloadContent` → back with the new version). + * * Event flow of one kill: `kill` (broadcast, the tick the mob dies — the * fact is only *queued*) → `saved` (to the killer, when the durable cell * commit settled) → `profile`. */ -import { watchZones, type EntityFields, type GameSocketFactory, type RenderedEntity, type WatchState } from '@hops-ops/distributed/game'; +import { + watchZones, + type EntityFields, + type FreezeMessage, + type GameSocketFactory, + type RenderedEntity, + type ResumeMessage, + type WatchState +} from '@hops-ops/distributed/game'; import * as pit from '../generated/mobpit/index.ts'; import type { DevApi } from '../shared/dev-api.ts'; @@ -228,6 +245,38 @@ export type RoomInfo = Readonly<{ hz: number; lateMs: { p50: number; p99: number }; droppedTicks: number; + /** The room's content version and freeze state (awake rooms only). */ + content: { version: string; freeze: 'running' | 'freezing' | 'frozen' } | null; +}>; + +/** The pack of a content version (unknown versions draw the built-in one). */ +export function contentPack(version: string): pit.ContentPack { + return pit.CONTENT_PACKS.find((p) => p.version === version) ?? pit.CONTENT_PACKS[0]!; +} + +/** What the client knows about live content. */ +export type ContentView = Readonly<{ + /** The content version this client runs (its pack). */ + version: string; + pack: pit.ContentPack; + /** The world's version (last Welcome / Resume of the own room). */ + worldVersion: string; + /** Running older content than the world. */ + stale: boolean; + /** The room's Freeze notice while frozen. */ + frozen: FreezeMessage | undefined; + lastResume: ResumeMessage | undefined; + /** The room's last `stale` notice (only the entrance lets you through). */ + staleNotice: pit.StaleEvent | undefined; + /** Reloads the rooms required (room, version). */ + reloads: readonly { zone: string; version: string }[]; +}>; + +/** The world's live content as `/dev/mobpit/content` reports it. */ +export type ContentInfo = Readonly<{ + world: string; + state: Readonly<{ version: string; target: string; phase: string; notes: string; since_ms: number; hard: boolean }> | null; + packs: readonly pit.ContentPack[]; }>; export type MobPitClient = pit.MobpitClient & @@ -261,6 +310,8 @@ export type MobPitClient = pit.MobpitClient & verdict(id: number): Promise; /** The neighbour rooms watched (`watchZones` over `neighbourRooms`), when enabled. */ neighbours(): ReadonlyMap; + /** Live content (version, pack, freeze, staleness). */ + content(): ContentView; /** Stop watching neighbours and close the client. */ close(): void; }>; @@ -272,10 +323,27 @@ export type MobPitClientOptions = Readonly<{ socket?: GameSocketFactory; /** Watch the 8 neighbour rooms (default true). */ neighbours?: boolean; + /** Content version to start on (the world's live one; default ''). */ + contentVersion?: string; + /** Simulate an old client: never patch content live on Resume. */ + pinContent?: boolean; + /** The world's live version, for a reload no room named (default ''). */ + liveVersion?: () => Promise; }>; export function createMobPitClient(options: MobPitClientOptions): MobPitClient { + const reloads: { zone: string; version: string }[] = []; + let typedRef: pit.MobpitClient | undefined; const typed = pit.createMobpitClient({ + contentVersion: options.contentVersion ?? '', + // A room required a reload (the entrance, for a stale client; or a + // fresh join on old content): "load" the pack of the version it + // named (the packs are built in), else the world's live one. + reloadContent: async (request) => { + const version = request.version ?? (await options.liveVersion?.()) ?? ''; + reloads.push({ zone: typedRef?.client.authority?.zoneId ?? '', version }); + return version; + }, url: (zone) => roomUrl(zone, options.roomsBase), ticket: options.api.ticketSource(), socket: options.socket, @@ -285,6 +353,44 @@ export function createMobPitClient(options: MobPitClientOptions): MobPitClient { // carried on through doorways in the next room's frame. prediction: { handoffFrame: pitHandoffFrame } }); + typedRef = typed; + // ---- live content ---- + let worldVersion = options.contentVersion ?? ''; + let frozen: FreezeMessage | undefined; + let lastResume: ResumeMessage | undefined; + let staleNotice: pit.StaleEvent | undefined; + typed.client.on('authority', (_zone, welcome) => { + worldVersion = welcome.contentVersion; + frozen = typed.client.authority?.connection.frozen ? frozen : undefined; + staleNotice = undefined; + }); + typed.client.on('freeze', (zone, message) => { + if (zone === typed.client.authority?.zoneId) frozen = message; + }); + typed.client.on('resume', (zone, message) => { + if (zone !== typed.client.authority?.zoneId) return; + frozen = undefined; + lastResume = message; + worldVersion = message.version; + // Patch live (the packs are built in) unless simulating an old build. + if (options.pinContent !== true && message.version !== typed.client.contentVersion) typed.client.setContentVersion(message.version); + }); + typed.on('stale', (notice, zoneId) => { + if (zoneId === typed.client.authority?.zoneId) staleNotice = notice; + }); + const content = (): ContentView => { + const version = typed.client.contentVersion; + return { + version, + pack: contentPack(version), + worldVersion, + stale: version !== worldVersion, + frozen, + lastResume, + staleNotice: version !== worldVersion ? staleNotice : undefined, + reloads + }; + }; const hello = latest( (h) => typed.on('hello', h), (value) => typed.client.setControlledEntity(value.entity) @@ -397,6 +503,7 @@ export function createMobPitClient(options: MobPitClientOptions): MobPitClient { pures, ready: Promise.all([hello.ready, typed.prediction?.ready, pures.ready]).then(([h]) => h), neighbours: () => watcher?.states() ?? new Map(), + content, shots, rangedStats, aimShot, @@ -507,6 +614,26 @@ export class MobPitApi { return this.#json('/dev/mobpit/leaderboard/rebuild', {}); } + /** The world's live content and every pack. */ + content(): Promise { + return this.#json('/dev/mobpit/content'); + } + + /** + * **Admin** (the services' admin secret): roll `version` out to every + * room (freeze → apply → resume with `notes`; `hard` reloads old + * clients at once instead of at the entrance). + */ + async rollout(secret: string, request: { version: string; notes?: string; hard?: boolean; freezeMs?: number }): Promise<{ version: string; notes: string }> { + const response = await fetch(`${this.base}/dev/mobpit/rollout`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-game-admin-secret': secret }, + body: JSON.stringify(request) + }); + if (!response.ok) throw new Error(`rollout: HTTP ${response.status} ${await response.text()}`); + return (await response.json()) as { version: string; notes: string }; + } + async #json(path: string, body?: unknown): Promise { const response = await fetch(`${this.base}${path}`, { method: body === undefined ? 'GET' : 'POST', diff --git a/examples/game/client/src/mobpit/main.ts b/examples/game/client/src/mobpit/main.ts index 5d622281..f0aa8dc7 100644 --- a/examples/game/client/src/mobpit/main.ts +++ b/examples/game/client/src/mobpit/main.ts @@ -36,6 +36,8 @@ import { InputPacer, type RenderedEntity } from '@hops-ops/distributed/game'; import { BUTTON, + CONTENT_PACKS, + GATE_ROOM, PIT_RULES, PIT_WORLD, isMob, @@ -47,6 +49,7 @@ import { pitFields, playerFields, type PitFields, + type ContentPack, type PitInput } from '../generated/mobpit/index.ts'; import { DevApi } from '../shared/dev-api.ts'; @@ -94,7 +97,14 @@ const name = params.get('name') ?? storedName; const api = new DevApi({ name: () => name }); const pitApi = new MobPitApi(); -const pit = createMobPitClient({ api }); +// `?old=1` simulates a client built before the next content update: it +// keeps its pack when the rooms move on, and only the entrance reloads it. +const pinContent = params.has('old'); +const pit = createMobPitClient({ + api, + pinContent, + liveVersion: async () => (await pitApi.content()).state?.version ?? '' +}); const { client } = pit; const canvas = document.getElementById('stage') as HTMLCanvasElement; @@ -190,6 +200,45 @@ const EVENT_TEXT: Record = { blessing: 'BLESSING — you heal five times as fast in this borough' }; deathBanner.style.display = 'none'; +// ---- live content: freeze overlay, old-client banner, admin panel ---- +const freezeOverlay = div('pit-freeze', document.body); +freezeOverlay.style.display = 'none'; +const staleBanner = div('pit-stale', document.body); +staleBanner.style.display = 'none'; +client.on('resume', (zone, message) => { + if (zone !== client.authority?.zoneId || message.version === '' && message.notes === '') return; + const pack = CONTENT_PACKS.find((p) => p.version === message.version); + toast(`World updated to ${pack?.name ?? message.version}${message.notes === '' ? '' : ` — ${message.notes}`}`); +}); +const adminParam = params.get('admin'); +const adminSecret = (() => { + try { + if (adminParam !== null) localStorage.setItem('game-demos.mobpit.admin', adminParam); + return adminParam ?? localStorage.getItem('game-demos.mobpit.admin') ?? undefined; + } catch { + return adminParam ?? undefined; + } +})(); +if (adminSecret !== undefined && adminSecret !== '') { + const panel = div('pit-admin', document.body); + panel.innerHTML = `

    World admin (dev, admin secret)

    + + +
    `; + const q = (k: string) => panel.querySelector(`[data-k="${k}"]`) as HTMLInputElement; + for (const pack of CONTENT_PACKS) { + const button = document.createElement('button'); + button.textContent = `Roll out ${pack.name}`; + button.addEventListener('click', () => { + q('result').textContent = 'rolling out…'; + pitApi + .rollout(adminSecret, { version: pack.version, notes: q('notes').value.trim() || undefined, hard: q('hard').checked }) + .then(() => (q('result').textContent = `rolling out ${pack.name}: every room freezes, then resumes on it`)) + .catch((error: unknown) => (q('result').textContent = String(error))); + }); + q('packs').append(button); + } +} function div(className: string, parent: HTMLElement): HTMLDivElement { const d = document.createElement('div'); @@ -365,6 +414,18 @@ for (const m of [mats.swing, mats.tele, mats.teleFill, mats.hpFill]) m.emissiveC mats.blade.emissiveColor = new Color3(0.6, 0.6, 0.7); const tierMats = new Map(); for (const [tier, t] of Object.entries(TIERS)) tierMats.set(Number(tier), material(scene, `mob-${tier}`, t.color)); +// The content pack this client runs tints the mobs (its own pack: an old +// client keeps drawing the old one). +let tintedFor: string | undefined; +function tintMobs(pack: ContentPack): void { + if (tintedFor === pack.version) return; + tintedFor = pack.version; + const tint = Color3.FromHexString(`#${pack.tint.toString(16).padStart(6, '0')}`); + for (const [tier, t] of Object.entries(TIERS)) { + const base = Color3.FromHexString(t.color); + tierMats.get(Number(tier))!.diffuseColor = pack.tint === 0 ? base : Color3.Lerp(base, tint, 0.6); + } +} // ---- the world: rooms at fixed places, floating origin at your room ---- // The stage's single floor and walls give way to one chunk per room. @@ -969,6 +1030,19 @@ stage.onFrame((now, dtMs) => { hud.row('World event', w === undefined || w.event === '' ? 'none (the borough schedules the next)' : `${w.event} · ${left} s left`); eventBanner.style.display = w !== undefined && w.event !== '' ? '' : 'none'; if (w !== undefined && w.event !== '') eventBanner.textContent = `${EVENT_TEXT[w.event] ?? w.event} · ${left} s`; + const c = pit.content(); + tintMobs(c.pack); + hud.row('Content', `${c.pack.name}${pinContent ? ' (old client: no live patch)' : ''} · world ${CONTENT_PACKS.find((p) => p.version === c.worldVersion)?.name ?? c.worldVersion}${c.reloads.length > 0 ? ` · ${c.reloads.length} reload(s)` : ''}`); + freezeOverlay.style.display = c.frozen === undefined ? 'none' : ''; + if (c.frozen !== undefined) { + const target = CONTENT_PACKS.find((p) => p.version === c.frozen!.version)?.name ?? c.frozen.version; + freezeOverlay.innerHTML = `SYSTEM: an administrator has paused the world to update it to ${target}.
    ${c.frozen.notes}`; + } + staleBanner.style.display = c.stale ? '' : 'none'; + if (c.stale) { + const world = CONTENT_PACKS.find((p) => p.version === c.worldVersion)?.name ?? c.worldVersion; + staleBanner.textContent = `Your client runs ${c.pack.name}; the world runs ${world}. Only the entrance (${GATE_ROOM}) lets you through — it updates you.${c.staleNotice === undefined ? '' : ' (that doorway is closed to you)'}`; + } const r = pit.rangedStats; hud.row('Ranged', `${r.shots} shots · ${r.confirmedHits} hits confirmed · ${r.agreed}/${r.resolved} as seen · last rewind ${r.lastAgeMs.toFixed(0)} ms${compensated ? '' : ' (compensation OFF)'}`); hud.row('Interest', hello === undefined ? '—' : `${hello.viewRadius} m ring · ${rendered.filter((e) => isMob(e) && !e.stale).length} mobs replicated`); @@ -1003,6 +1077,9 @@ window.mobpit = { client, api, pitApi, pit, scene, rooms, boards, camera: stage. // The same browser is the same player: back in the room you were last in. (async () => { + // Start on the world's live content (the packs are built in). + const live = await pitApi.content().catch(() => undefined); + client.setContentVersion(live?.state?.version ?? ''); const zone = forcedZone ?? (await pitApi.startRoom(api.guest).catch(() => DEFAULT_ZONE)); origin = parseRoom(zone) ?? origin; await client.connect(zone); diff --git a/examples/game/client/src/mobpit/style.css b/examples/game/client/src/mobpit/style.css index 431037b5..8d4a52d7 100644 --- a/examples/game/client/src/mobpit/style.css +++ b/examples/game/client/src/mobpit/style.css @@ -205,3 +205,56 @@ pointer-events: none; z-index: 6; } + +.pit-freeze { + position: fixed; + top: 40%; + left: 50%; + transform: translate(-50%, -50%); + max-width: 520px; + padding: 16px 22px; + border-radius: 8px; + background: rgba(20, 60, 110, 0.92); + color: #fff; + text-align: center; + line-height: 1.5; + pointer-events: none; + z-index: 6; +} + +.pit-stale { + position: fixed; + bottom: 70px; + left: 50%; + transform: translateX(-50%); + padding: 8px 16px; + border-radius: 6px; + background: rgba(170, 110, 20, 0.9); + color: #fff; + font-weight: 600; + pointer-events: none; + z-index: 5; +} + +.pit-admin { + position: fixed; + right: 12px; + bottom: 12px; + width: 260px; + padding: 8px 10px; + border-radius: 6px; + background: rgba(18, 15, 20, 0.85); + color: #ddd; + font-size: 12px; + z-index: 5; +} + +.pit-admin input:not([type='checkbox']) { + width: 100%; + box-sizing: border-box; + margin-bottom: 4px; +} + +.pit-admin button { + margin: 4px 4px 0 0; +} diff --git a/examples/game/client/tests/helpers/mobpit.mjs b/examples/game/client/tests/helpers/mobpit.mjs index f180080f..94458ff7 100644 --- a/examples/game/client/tests/helpers/mobpit.mjs +++ b/examples/game/client/tests/helpers/mobpit.mjs @@ -12,6 +12,9 @@ import { BUTTON, isMob, isPlayer, mobFields, playerFields } from '../../src/gene import { freePort, startCelld } from './celld.mjs'; import { sleep, startDemo } from './demo-server.mjs'; +/** The services' admin secret in the tests (live content rollouts). */ +export const ADMIN_SECRET = 'mobpit-test-admin-secret'; + export function tempDataDir() { return mkdtempSync(join(tmpdir(), 'mobpit-js-')); } @@ -26,7 +29,7 @@ export function tempDataDir() { export async function startPit(demo = 'mobpit', vars = {}) { const port = await freePort(); const celld = await startCelld({ relayPort: port, vars: { ZONE_HIBERNATE_AFTER_MS: '1500', ...vars } }); - const env = { ...celld.env, MOBPIT_DATA_DIR: tempDataDir() }; + const env = { ...celld.env, MOBPIT_DATA_DIR: tempDataDir(), DG_DEMO_ADMIN_SECRET: ADMIN_SECRET }; let server; try { server = await startDemo(demo, { env, port }); diff --git a/examples/game/client/tests/mobpit-content-headless.test.mjs b/examples/game/client/tests/mobpit-content-headless.test.mjs new file mode 100644 index 00000000..4bb09e67 --- /dev/null +++ b/examples/game/client/tests/mobpit-content-headless.test.mjs @@ -0,0 +1,136 @@ +/** + * Headless Mob Pit live content update (Node): the rooms on their own + * `celld dev`, the services binary, scripted players. + * + * An admin rolls a new content pack out to every live room through the + * world's rollout cell: the rooms freeze (change notes, inputs held), + * switch to the pack and resume. A normal client patches live; an old one + * (pinned) keeps its pack, cannot leave through any doorway but the + * entrance's and is reloaded there. A fresh join on old content is told to + * reload; a room that never ran wakes on the live version before admitting. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { playerFields } from '../src/generated/mobpit/index.ts'; +import { createMobPitClient, MobPitApi, parseRoom } from '../src/mobpit/client.ts'; +import { DevApi } from '../src/shared/dev-api.ts'; +import { eventually, memoryStorage, sleep, until } from './helpers/demo-server.mjs'; +import { ADMIN_SECRET, startPit, walkThroughDoor } from './helpers/mobpit.mjs'; + +/** Walk towards side `[dx, dy]` of the current room for `ms` (no handoff expected). */ +async function pushAgainst(pit, [dx, dy], ms) { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + const at = pit.position(); + const [rx, ry] = parseRoom(pit.room()); + let input = { mx: 0, my: 0, buttons: 0 }; + if (at !== undefined) { + const tx = rx * 40 + (dx !== 0 ? dx * 24 : 1.7); + const ty = ry * 40 + (dy !== 0 ? dy * 24 : 1.7); + const len = Math.max(Math.hypot(tx - at.x, ty - at.y), 1e-3); + input = { mx: Math.round(((tx - at.x) / len) * 127), my: Math.round(((ty - at.y) / len) * 127), buttons: 0 }; + } + pit.client.sendInput(input); + await sleep(1000 / 30); + } +} + +test('Mob Pit live content: freeze → apply → resume, old clients held until the entrance', { timeout: 600_000 }, async () => { + const server = await startPit('mobpit'); + const clients = []; + try { + const pitApi = new MobPitApi(server.httpUrl, server.roomsUrl); + const liveVersion = async () => (await pitApi.content()).state?.version ?? ''; + const join = async (name, zone, options = {}) => { + const api = new DevApi({ base: server.httpUrl, name: () => name, storage: memoryStorage() }); + const pit = createMobPitClient({ api, roomsBase: server.roomsUrl, neighbours: false, liveVersion, ...options }); + clients.push(pit.client); + await pit.client.connect(zone); + await pit.ready; + await until(() => pit.client.predictor?.active, 5_000, `${name} bound`); + return pit; + }; + const before = await pitApi.content(); + assert.equal(before.state, null, 'no rollout yet: the built-in pack'); + assert.deepEqual( + before.packs.map((p) => p.version), + ['', 'frostfall', 'emberfall'] + ); + + const ana = await join('Ana', 'pit.0.0'); + const bo = await join('Bo', 'pit.0.0', { pinContent: true }); + // Bo (an old build) walks into the next room before the update. + const east = await walkThroughDoor(bo, [1, 0]); + assert.equal(east.room, 'pit.1.0'); + + // Only the admin rolls out, and only known packs. + await assert.rejects(pitApi.rollout('wrong', { version: 'frostfall' }), /HTTP 401/); + await assert.rejects(pitApi.rollout(ADMIN_SECRET, { version: 'nope' }), /HTTP 400/); + const notes = 'Frostfall is here: slower, tougher mobs.'; + await pitApi.rollout(ADMIN_SECRET, { version: 'frostfall', notes, freezeMs: 2_500 }); + await assert.rejects(pitApi.rollout(ADMIN_SECRET, { version: 'emberfall' }), /in progress/, 'one rollout at a time'); + + // Every live room freezes with the notes; inputs are held. + await until(() => ana.content().frozen !== undefined && bo.content().frozen !== undefined, 10_000, 'both rooms frozen'); + assert.equal(ana.content().frozen.notes, notes); + assert.equal(ana.content().frozen.version, 'frostfall'); + assert.equal(ana.client.sendInput({ mx: 127, my: 0, buttons: 0 }), undefined, 'no inputs while frozen'); + for (const room of ['pit.0.0', 'pit.1.0']) { + const info = await pitApi.roomInfo(room); + assert.equal(info.content.freeze, 'frozen', `${room} frozen`); + } + + // …then resume on the new pack. + await until(() => ana.content().frozen === undefined && bo.content().frozen === undefined, 15_000, 'both rooms resumed'); + assert.equal(ana.content().lastResume.version, 'frostfall'); + assert.equal(ana.content().version, 'frostfall', 'a normal client patches live'); + assert.equal(ana.content().pack.name, 'Frostfall'); + assert.equal(ana.content().stale, false); + assert.equal(bo.content().version, '', 'the old client keeps its pack'); + assert.equal(bo.content().stale, true); + for (const room of ['pit.0.0', 'pit.1.0']) { + assert.equal((await pitApi.roomInfo(room)).content.version, 'frostfall', `${room} runs frostfall`); + } + const after = await eventually(async () => { + const c = await pitApi.content(); + return c.state?.phase === 'running' && c; + }, 10_000, 'rollout settled'); + assert.equal(after.state.version, 'frostfall'); + + // The old client may not leave through another doorway… + await pushAgainst(bo, [1, 0], 13_000); + assert.equal(bo.room(), 'pit.1.0', 'held in its room'); + assert.equal(bo.content().staleNotice?.gate, 'pit.0.0', 'told to head to the entrance'); + // …but the entrance lets it through and reloads it there. + const back = await walkThroughDoor(bo, [-1, 0], 45_000); + assert.equal(back.room, 'pit.0.0'); + await until(() => bo.client.contentVersion === 'frostfall', 10_000, 'reloaded at the entrance'); + assert.deepEqual( + bo.content().reloads.map((r) => r.version), + ['frostfall'] + ); + assert.equal(bo.content().stale, false); + await until(() => bo.client.predictor?.active && bo.room() === 'pit.0.0', 10_000, 'back in control after the reload'); + const me = playerFields(bo.me()); + assert.ok(Math.abs(me.x) < 20 && Math.abs(me.y) < 20, 'the same body, in the entrance'); + + // A fresh join on old content is told to reload, then gets in. + const cy = await join('Cy', 'pit.0.0', { contentVersion: '' }); + assert.equal(cy.client.contentVersion, 'frostfall'); + assert.deepEqual( + cy.content().reloads.map((r) => r.version), + ['frostfall'] + ); + + // A room that never ran wakes on the live version before admitting. + const north = await walkThroughDoor(ana, [0, 1]); + assert.equal(north.room, 'pit.0.1'); + assert.equal(ana.content().worldVersion, 'frostfall', 'the woken room welcomed on frostfall'); + assert.equal((await pitApi.roomInfo('pit.0.1')).content.version, 'frostfall'); + console.error(`rollout frostfall: rooms froze with notes, resumed; old client reloaded at the entrance after ${bo.content().reloads.length} reload`); + } finally { + for (const c of clients) c.close(); + await server.stop(); + } +}); diff --git a/examples/game/client/tests/prediction-wasm.test.mjs b/examples/game/client/tests/prediction-wasm.test.mjs index c1ca3b6c..16c9d65f 100644 --- a/examples/game/client/tests/prediction-wasm.test.mjs +++ b/examples/game/client/tests/prediction-wasm.test.mjs @@ -59,7 +59,7 @@ const surfaces = readdirSync(GENERATED) }); test('every predicting demo has a compiled step and a native trace', () => { - assert.deepEqual(surfaces.map((s) => s.name).sort(), ['freeze', 'mobpit']); + assert.deepEqual(surfaces.map((s) => s.name).sort(), ['mobpit']); assert.equal(traces.abi, 1); for (const { name, predict } of surfaces) { assert.ok(existsSync(new URL(`${name}/wasm/${name}.wasm`, GENERATED)), `${name}: run \`run.sh gen\``); @@ -193,7 +193,7 @@ test('cost: generating a room in WASM', async () => { test('cost: one predicted step and a reconciliation replaying 10 inputs at 30 Hz', async () => { const report = {}; - for (const name of ['mobpit', 'freeze']) { + for (const name of ['mobpit']) { const generated = await import(`../src/generated/${name}/index.ts`); const prediction = generated[`create${name[0].toUpperCase()}${name.slice(1)}Prediction`](); await prediction.ready; diff --git a/examples/game/client/vite.config.ts b/examples/game/client/vite.config.ts index c356a119..f9ee41f8 100644 --- a/examples/game/client/vite.config.ts +++ b/examples/game/client/vite.config.ts @@ -7,7 +7,7 @@ import { resolve } from 'node:path'; import { defineConfig } from 'vite'; -const DEMOS = ['mobpit', 'leaderboard', 'freeze']; +const DEMOS = ['mobpit', 'leaderboard']; const server = process.env.GAME_DEMOS_SERVER ?? 'http://127.0.0.1:2567'; const rooms = process.env.GAME_DEMOS_ROOMS ?? 'http://127.0.0.1:2570'; diff --git a/examples/game/run.sh b/examples/game/run.sh index 92301eb3..9c9fabb1 100755 --- a/examples/game/run.sh +++ b/examples/game/run.sh @@ -40,7 +40,7 @@ # # celld: Mob Pit's rooms and the demos' durable cells (examples/game/cells: # Mob Pit's rooms, Crawler, presence, zone-activity and hood / borough / -# city tier cells, The Freeze's gem and content cells) run on celld, never natively; state persists in +# city tier cells, the world's content rollout) run on celld, never natively; state persists in # .data//celld (Mob Pit: MOBPIT_DATA_DIR/celld, the fleet's bucket in # a RustFS volume of its compose project). Fleet ports: nodes' public # listeners GAME_DEMOS_PORT + 10.., operators + 20.., RustFS + 30 @@ -60,13 +60,11 @@ SVELTEKIT="$HERE/sveltekit" BIN="$SERVER/target/debug/game-demos" SERVICE_BIN="$SERVER/target/debug/mobpit-service" CELLS="$HERE/cells" -DEMOS=(mobpit leaderboard freeze) -# Demos whose durable state lives in celld cells. -CELLD_DEMOS=(mobpit leaderboard freeze) +DEMOS=(mobpit leaderboard) export MOBPIT_DATA_DIR="${MOBPIT_DATA_DIR:-$HERE/.data/mobpit}" # Game surfaces with a generated client (`game_demos::::game_surface`); # the leaderboard page uses Mob Pit's. -SURFACES=(mobpit freeze) +SURFACES=(mobpit) CLI="$REPO/target/debug/distributed" log() { printf '\033[1;36m[demos]\033[0m %s\n' "$*"; } @@ -115,7 +113,7 @@ ensure_celld() { # sources changed. ensure_cells() { local marker="$CELLS/build/worker/shim.mjs" - if [[ ! -f "$marker" ]] || [[ -n "$(find "$CELLS/src" "$CELLS/Cargo.toml" "$SERVER/crawler" "$SERVER/rooms" "$SERVER/sim" "$REPO/src" "$REPO/distributed_game/src" -newer "$marker" -name '*.rs' -print -quit 2>/dev/null)" ]] || + if [[ ! -f "$marker" ]] || [[ -n "$(find "$CELLS/src" "$CELLS/Cargo.toml" "$SERVER/crawler" "$SERVER/rooms" "$SERVER/sim" "$SERVER/tiers" "$REPO/src" "$REPO/distributed_game/src" -newer "$marker" -name '*.rs' -print -quit 2>/dev/null)" ]] || [[ "$CELLS/Cargo.toml" -nt "$marker" ]]; then command -v worker-build >/dev/null || { echo "worker-build not found: cargo install worker-build --locked" >&2 @@ -384,7 +382,9 @@ run_mobpit() { fi local log_file log_file="$(mktemp -t game-demos.XXXXXX)" - DISTRIBUTED_GAME_SECRET="$secret" "$BIN" mobpit --dev --port "$port" \ + local admin + admin="$(dev_secret "$MOBPIT_DATA_DIR" admin-secret)" + DISTRIBUTED_GAME_SECRET="$secret" DG_DEMO_ADMIN_SECRET="$admin" "$BIN" mobpit --dev --port "$port" \ --data-dir "$MOBPIT_DATA_DIR" --celld-url "$CELLD_URL" >"$log_file" 2>&1 & local server_pid=$! trap 'kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true; [[ -z "$CELLD_PID" ]] || kill_tree "$CELLD_PID"; stop_fleet; rm -f "$log_file"' EXIT INT TERM @@ -392,51 +392,13 @@ run_mobpit() { sed -n '2p' "$log_file" echo log "open http://localhost:$client_port/$demo/ (services :$port, rooms on celld ${ROOMS_URLS}; dev routes ON — local only)" + log "admin http://localhost:$client_port/mobpit/?admin=$admin (live content updates; add &old=1 in another tab for an old client)" echo cd "$CLIENT" GAME_DEMOS_SERVER="http://127.0.0.1:$port" GAME_DEMOS_ROOMS="$ROOMS_URLS" \ GAME_DEMOS_CLIENT_PORT="$client_port" npx vite } -run_demo() { - local demo="$1" - local port="${GAME_DEMOS_PORT:-2567}" - local client_port="${GAME_DEMOS_CLIENT_PORT:-5173}" - ensure_sdk - ensure_client - ensure_server - ensure_wasm - - CELLD_PID="" - if [[ " ${CELLD_DEMOS[*]} " == *" $demo "* ]]; then - local project="$HERE/.data/$demo/celld" - [[ "$demo" == mobpit || "$demo" == leaderboard ]] && project="$MOBPIT_DATA_DIR/celld" - start_celld "$project" "${CELLD_PORT:-$((port + 3))}" "$port" - fi - local log_file secret - log_file="$(mktemp -t game-demos.XXXXXX)" - secret="${DISTRIBUTED_GAME_SECRET:-$(dev_secret "$HERE/.data/$demo")}" - [[ "$demo" == mobpit || "$demo" == leaderboard ]] && secret="$(dev_secret "$MOBPIT_DATA_DIR")" - DISTRIBUTED_GAME_SECRET="$secret" "$BIN" "$demo" --dev --port "$port" >"$log_file" 2>&1 & - local server_pid=$! - trap 'kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true; [[ -z "$CELLD_PID" ]] || kill_tree "$CELLD_PID"; rm -f "$log_file"' EXIT INT TERM - for _ in $(seq 1 100); do - grep -q '^LISTENING' "$log_file" && break - if ! kill -0 "$server_pid" 2>/dev/null; then - cat "$log_file" - echo "game-demos $demo failed to start" >&2 - exit 1 - fi - sleep 0.1 - done - sed -n '2p' "$log_file" - echo - log "open http://localhost:$client_port/$demo/ (server :$port, dev routes ON — local only)" - echo - cd "$CLIENT" - GAME_DEMOS_SERVER="http://127.0.0.1:$port" GAME_DEMOS_CLIENT_PORT="$client_port" npx vite -} - wait_listening() { local log_file="$1" pid="$2" what="$3" for _ in $(seq 1 100); do @@ -535,8 +497,8 @@ run_sveltekit() { smoke() { ensure_sdk ensure_client - # The celld-backed tests (Mob Pit, The Freeze) start their - # own `celld dev` (CELLD_BIN) on the cells worker. + # The celld-backed tests start their own `celld dev` (CELLD_BIN) on + # the cells worker. ensure_celld ensure_cells # The SvelteKit test's leaderboard is the Redis board. Only that test gets @@ -575,10 +537,8 @@ case "${1:-}" in list) printf '%s\n' "${DEMOS[@]}" sveltekit ;; "" | -h | --help | help) awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$0" ;; *) - if [[ "$1" == mobpit || "$1" == leaderboard ]]; then + if [[ " ${DEMOS[*]} " == *" $1 "* ]]; then run_mobpit "$1" "${2:-}" "${3:-3}" - elif [[ " ${DEMOS[*]} " == *" $1 "* ]]; then - run_demo "$1" else echo "unknown demo '$1' (known: ${DEMOS[*]})" >&2 exit 2 diff --git a/examples/game/server/rooms/src/celld.rs b/examples/game/server/rooms/src/celld.rs index 2059d883..521af3a9 100644 --- a/examples/game/server/rooms/src/celld.rs +++ b/examples/game/server/rooms/src/celld.rs @@ -29,6 +29,9 @@ impl CelldZone for MobPit { // A doorway into a sleeping room waits for it to wake while the // client keeps predicting: keep up to 3 s of its inputs. input_capacity: 90, + // A live content update keeps old clients playing where they + // are; they reload when they walk into the entrance. + stale_policy: distributed_game::StalePolicy::SafeRoom { roam: false }, // Ranged shots are rewound at most this far (lag compensation). rewind: distributed_game::RewindConfig { max_rewind: std::time::Duration::from_millis(crate::zone::MAX_REWIND_MS), diff --git a/examples/game/server/rooms/src/zone.rs b/examples/game/server/rooms/src/zone.rs index 6c4202d3..0e688fab 100644 --- a/examples/game/server/rooms/src/zone.rs +++ b/examples/game/server/rooms/src/zone.rs @@ -362,6 +362,16 @@ pub enum PitEvent { /// Where the arrow stopped (height). z: f32, }, + /// To a player whose client runs older content than the room (a live + /// content update happened): only the entrance (a gate) lets it through, + /// and reloads it there. Sent when it tries another doorway. + #[game_event(id = 10)] + Stale { + /// The gate room (`pit.0.0`). + gate: String, + /// The world's content version. + version: String, + }, /// The hood's boss fell here. #[game_event(id = 8)] BossDown { @@ -443,6 +453,8 @@ pub fn game_surface() -> GameSurface { &game_sim::mobpit::PATTERNS.map(String::from).to_vec(), ) .constant("pit_world", &pit_world()) + .constant("content_packs", &content_packs()) + .constant("gate_room", &GATE_ROOM.to_string()) } /// Input bit: attack (bit 0 is the Arena's dash). @@ -501,6 +513,71 @@ fn mob_height(tier: u8) -> f32 { } const PLAYER_RESPAWN_TICKS: u64 = 90; +/// One content pack: what a content version changes in the rooms (mob +/// pace and toughness) and how clients running it draw them (the tint). +/// The live version comes from the world's content rollout +/// (`distributed_game::world::content_rollout`); the generated +/// `CONTENT_PACKS` gives the pages the same table. +#[derive(Debug, Clone, PartialEq, GameRecord)] +pub struct ContentPack { + /// Content version (`""`: the built-in pack). + pub version: String, + /// Display name. + pub name: String, + /// Mob speed multiplier. + pub mob_pace: f32, + /// Mob hit points, percent of the base. + pub mob_hp_pct: u16, + /// Mob tint (0xRRGGBB, 0: none). + pub tint: u32, + /// Default change notes. + pub notes: String, +} + +/// Every content pack, the built-in one first. +pub fn content_packs() -> Vec { + let pack = |version: &str, name: &str, mob_pace, mob_hp_pct, tint, notes: &str| ContentPack { + version: version.into(), + name: name.into(), + mob_pace, + mob_hp_pct, + tint, + notes: notes.into(), + }; + vec![ + pack("", "Classic", 1.0, 100, 0, ""), + pack( + "frostfall", + "Frostfall", + 0.7, + 120, + 0x8fd3ff, + "Frostfall: the mobs are frozen stiff (30 % slower) but tougher (+20 % HP).", + ), + pack( + "emberfall", + "Emberfall", + 1.25, + 85, + 0xff7a3a, + "Emberfall: the mobs burn hot (25 % faster) and brittle (-15 % HP).", + ), + ] +} + +/// The pack of `version` (unknown versions run the built-in pack). +pub fn content_pack(version: &str) -> ContentPack { + let packs = content_packs(); + packs + .iter() + .find(|p| p.version == version) + .cloned() + .unwrap_or_else(|| packs[0].clone()) +} + +/// The gate room: stale clients reload when they walk into it. +pub const GATE_ROOM: &str = "pit.0.0"; + /// Mob tier stats: (max hp, speed m/s, damage, radius, telegraph radius). fn tier_stats(tier: u8) -> (i32, f32, i32, f32, f32) { match tier { @@ -525,6 +602,11 @@ fn max_hp(tier: u8, difficulty: u32) -> i32 { tier_stats(tier).0 * (4 + d) / 4 } +/// [`max_hp`] under a content pack. +fn pack_hp(tier: u8, difficulty: u32, pack: &ContentPack) -> i32 { + (max_hp(tier, difficulty) * i32::from(pack.mob_hp_pct) / 100).max(1) +} + /// Damage of a mob of `tier` at hood tier `difficulty` (+20 % per tier). fn damage(tier: u8, difficulty: u32) -> i32 { let d = difficulty.min(mobpit_tiers::MAX_TIER) as i32; @@ -620,6 +702,8 @@ struct Mob { wander: (f32, f32), /// The hood tier its hit points are scaled to (for the HP bar). difficulty: u32, + /// The content pack's hit points (percent, for the HP bar). + hp_pct: u16, } struct Player { @@ -640,6 +724,8 @@ struct Player { shot_anim: u8, /// Handed to a neighbouring room: no more steps here. leaving: bool, + /// Told it must reload at the entrance (it runs older content). + told_stale: bool, } struct PendingKill { @@ -732,6 +818,8 @@ pub struct MobPit { awake_reported: bool, /// Ranged shots of this tick, resolved after the players stepped. fires: Vec, + /// The content pack of the room's content version. + pack: ContentPack, } /// A ranged shot to resolve. @@ -1121,9 +1209,25 @@ impl MobPit { .collect(); for (player, (dx, dy)) in crossing_now { let target = room_zone((rx + dx, ry + dy)); + // A client on older content (a live update happened) only + // passes into the entrance, which reloads it. + let held = ctx.is_stale(player) && target != GATE_ROOM; let p = self.players.get_mut(&player).expect("player"); let carried = encode_carried(&into_neighbour((dx, dy), p.state)); - if parse_room(&target).is_some() && ctx.handoff(player, &target, carried).is_ok() { + if held && !p.told_stale { + p.told_stale = true; + ctx.send( + player, + &PitEvent::Stale { + gate: GATE_ROOM.into(), + version: ctx.content_version().to_string(), + }, + ); + } + if !held + && parse_room(&target).is_some() + && ctx.handoff(player, &target, carried).is_ok() + { p.leaving = true; } else { // The edge of the world: back inside the wall line. @@ -1302,18 +1406,22 @@ impl MobPit { // The borough's world event: an invasion (mobs aggro from twice as // far and come back at once) or a frenzy (mobs 40 % faster). let invasion = self.event() == "invasion"; - let pace = if self.event() == "frenzy" { 1.4 } else { 1.0 }; + let pace = if self.event() == "frenzy" { 1.4 } else { 1.0 } * self.pack.mob_pace; let aggro = if invasion { AGGRO * 2.0 } else { AGGRO }; for i in 0..self.mobs.len() { let tier = self.mobs[i].tier; let (_, speed, _, radius, tele) = tier_stats(tier); - let (max_hp, damage) = (max_hp(tier, difficulty), damage(tier, difficulty)); + let (max_hp, damage) = ( + pack_hp(tier, difficulty, &self.pack), + damage(tier, difficulty), + ); let target_pos = |t: Option| { t.and_then(|t| living.iter().find(|(id, _, _)| *id == t)) .map(|(_, x, y)| (*x, *y)) }; let m = &mut self.mobs[i]; m.difficulty = difficulty; + m.hp_pct = self.pack.mob_hp_pct; match m.state { MobState::Dead => { // A boss returns only once its hood says so. @@ -1504,6 +1612,7 @@ impl MobPit { fire_cd: 0, shot_anim: 0, leaving: false, + told_stale: false, }, ); self.greet(ctx, player); @@ -1554,7 +1663,7 @@ impl Zone for MobPit { .one(|z: &Self| z) } - fn on_create(_ctx: &mut Ctx<'_, Self>, room: RoomConfig) -> Self { + fn on_create(ctx: &mut Ctx<'_, Self>, room: RoomConfig) -> Self { let layout = pit_room(&PitRoomContext { seed: room.seed, x: room.room.0, @@ -1573,7 +1682,7 @@ impl Zone for MobPit { x: home.0, y: home.1, face: 0.0, - hp: max_hp(slot.tier, 0), + hp: pack_hp(slot.tier, 0, &content_pack(ctx.content_version())), state: MobState::Idle, timer: 0, target: None, @@ -1581,6 +1690,7 @@ impl Zone for MobPit { respawn_at: 0, wander: home, difficulty: 0, + hp_pct: content_pack(ctx.content_version()).mob_hp_pct, } }) .collect(); @@ -1614,6 +1724,7 @@ impl Zone for MobPit { }, awake_reported: false, fires: Vec::new(), + pack: content_pack(ctx.content_version()), } } @@ -1680,6 +1791,21 @@ impl Zone for MobPit { } } + /// The entrance is the safe room of a live content update: a client on + /// older content is let in there (and only there) and reloaded. + fn is_gate(&self) -> bool { + self.room.room == (0, 0) + } + + /// A live content update: the room runs the new pack from this tick + /// (facts and stamps carry the new version). + fn on_content_version_changed(&mut self, _ctx: &mut Ctx<'_, Self>, _from: &str, to: &str) { + self.pack = content_pack(to); + for p in self.players.values_mut() { + p.told_stale = false; + } + } + /// A watcher (a player next door, a board page): the whole room. fn on_observe(&mut self, ctx: &mut Ctx<'_, Self>, observer: ObserverId) { ctx.set_view(observer, View::new(0.0, 0.0, WATCH_RADIUS)); @@ -1910,7 +2036,9 @@ impl Zone for MobPit { // Back home-side where it stood, calm again. m.x = saved.x; m.y = saved.y; - m.hp = saved.hp.clamp(1, max_hp(m.tier, mobpit_tiers::MAX_TIER)); + m.hp = saved + .hp + .clamp(1, pack_hp(m.tier, mobpit_tiers::MAX_TIER, &self.pack)); m.state = MobState::Return; } } @@ -1949,7 +2077,7 @@ impl Project for Mob { if self.state == MobState::Dead { return None; } - let max_hp = max_hp(self.tier, self.difficulty); + let max_hp = (max_hp(self.tier, self.difficulty) * i32::from(self.hp_pct) / 100).max(1); let tele = if self.state == MobState::Windup { 255 - (u32::from(self.timer) * 255 / u32::from(WINDUP_TICKS)) } else { @@ -2012,6 +2140,11 @@ pub struct PitStats { impl MobPit { /// Current numbers. + /// The content pack the room runs. + pub fn pack(&self) -> &ContentPack { + &self.pack + } + pub fn stats(&self) -> PitStats { PitStats { pending: self.pending.len() + self.backlog.len(), diff --git a/examples/game/server/rooms/tests/rooms.rs b/examples/game/server/rooms/tests/rooms.rs index 39030122..d3c3c3af 100644 --- a/examples/game/server/rooms/tests/rooms.rs +++ b/examples/game/server/rooms/tests/rooms.rs @@ -627,3 +627,134 @@ fn an_arrow_hits_the_mob_it_was_aimed_at_and_obstacles_stop_it() { "stopped at the pillar: {from_centre}" ); } + +/// Walk `player` towards `(tx, ty)` (room-local) until the room hands it +/// off or `frames` pass; returns the handoff target and the events seen. +fn walk_to( + room: &mut ZoneHost, + clock: &ManualClock, + client: ClientId, + player: distributed_game::PlayerId, + seq: &mut u32, + (tx, ty): (f32, f32), + frames: u32, +) -> (Option, Vec) { + let mut seen = Vec::new(); + for _ in 0..frames { + let Some(me) = room.zone().player_position(player) else { + break; + }; + let (dx, dy) = (tx - me.0, ty - me.1); + let len = (dx * dx + dy * dy).sqrt().max(1e-3); + *seq += 1; + let input = PitInput { + mx: (dx / len * 127.0) as i8, + my: (dy / len * 127.0) as i8, + ..PitInput::default() + }; + room.push_input( + client, + *seq, + *seq, + &distributed_game::GameInput::to_payload(&input), + ); + let out = tick(room, clock); + seen.extend(events(&out)); + if let Some(target) = messages(&out).into_iter().find_map(|m| match m { + ServerMessage::Handoff { target_zone, .. } => Some(target_zone), + _ => None, + }) { + return (Some(target), seen); + } + } + (None, seen) +} + +#[test] +fn a_content_update_switches_the_pack_stamps_facts_and_holds_old_clients() { + use distributed_game::StalePolicy; + + let clock = ManualClock::new(); + let (facts, _script) = FactQueue::scripted(256); + let mut room = ZoneHost::::new( + "pit.1.0", + HostConfig { + stale_policy: StalePolicy::SafeRoom { roam: false }, + lifecycle_facts: true, + ..HostConfig::default() + }, + Arc::new(clock.clone()), + RoomConfig::for_zone("pit.1.0", DEFAULT_SEED).expect("room"), + ) + .with_facts(facts.with_journal()) + .with_transfer_issuer(|request| Some(format!("transfer:{}", request.target_zone))); + let (old, old_player) = join(&mut room, "old"); + let (new, new_player) = join(&mut room, "new"); + tick(&mut room, &clock); + + // The live update: the room runs frostfall from here on. + room.set_content_version("frostfall"); + assert_eq!(room.zone().pack().name, "Frostfall"); + // The patched client says so; the old one does not. + assert!(room.content_patched(new, "frostfall")); + assert!( + !room.content_patched(new, "emberfall"), + "never ahead of the room" + ); + + // The old client may not leave through the east doorway (pit.2.0)… + let mut seq = 0; + let (target, seen) = walk_to( + &mut room, + &clock, + old, + old_player, + &mut seq, + (24.0, 1.7), + 400, + ); + assert_eq!(target, None, "held in the room"); + assert!( + seen.iter().any(|e| matches!(e, PitEvent::Stale { gate, version } if gate == "pit.0.0" && version == "frostfall")), + "told to head to the entrance" + ); + // …but through the west one, into the entrance (the gate). + let (target, _) = walk_to( + &mut room, + &clock, + old, + old_player, + &mut seq, + (-24.0, 1.7), + 600, + ); + assert_eq!(target.as_deref(), Some("pit.0.0")); + // The patched client goes east. + let mut seq = 0; + let (target, _) = walk_to( + &mut room, + &clock, + new, + new_player, + &mut seq, + (24.0, 1.7), + 600, + ); + assert_eq!(target.as_deref(), Some("pit.2.0")); + + // Facts after the update carry its version. + let emitted = room.take_emitted_facts(); + assert!(!emitted.is_empty(), "lifecycle facts were emitted"); + let after: Vec<_> = emitted + .iter() + .filter(|f| f.stamp().content_version == "frostfall") + .collect(); + assert!( + !after.is_empty(), + "facts after the update are stamped frostfall: {:?}", + emitted + .iter() + .map(|f| (f.command(), f.stamp().content_version.clone())) + .collect::>() + ); +} diff --git a/examples/game/server/sim/src/arena.rs b/examples/game/server/sim/src/arena.rs index 78eb2aed..9e48505f 100644 --- a/examples/game/server/sim/src/arena.rs +++ b/examples/game/server/sim/src/arena.rs @@ -1,10 +1,11 @@ -//! The Arena's deterministic movement step, shared by every demo with an -//! Arena capsule (Arena, Mob Pit, The Freeze, Boss Ripple). +//! The planar capsule movement (stick, dash, round obstacles) under Mob +//! Pit's predicted step ([`crate::mobpit::pit_step`]), with the 40 m floor +//! it was born on (the first Arena demo's; [`step`] keeps its closed walls +//! for the unit tests here). //! -//! The server runs [`step`] in `on_tick`; the browser predicts its own -//! capsule with the **same code** compiled to WebAssembly ([`arena_step`] -//! is the `#[predict]` declaration; `distributed game-wasm` builds it). -//! Prediction is exact because: +//! The server runs it in `on_tick`; the browser predicts its own capsule +//! with the **same code** compiled to WebAssembly. Prediction is exact +//! because: //! //! - one input = one command frame of exactly [`DT`] seconds, independent //! of the server's measured tick length (the frame's `dt` is ignored); @@ -17,7 +18,7 @@ //! `tests/prediction-wasm.test.mjs` proves it against the native build //! over recorded input streams. -use distributed_game::{predict, FieldType, GameInput, GameRecord}; +use distributed_game::{FieldType, GameInput, GameRecord}; /// Command frame length (s): the 30 Hz tick. pub const DT: f32 = 1.0 / 30.0; @@ -62,8 +63,7 @@ pub struct Pillar { pub r: f32, } -/// What the pages draw of the Arena floor (generated `ARENA_FLOOR`, shared by -/// every page with an Arena capsule): the simulation's own constants. +/// The floor's constants (Mob Pit's `pit_world` carries its pillars). #[derive(Debug, Clone, PartialEq, GameRecord)] pub struct ArenaFloor { /// Floor half extent (m). @@ -76,7 +76,7 @@ pub struct ArenaFloor { pub pillars: Vec, } -/// The `ARENA_FLOOR` constant. +/// The floor. pub fn arena_floor() -> ArenaFloor { ArenaFloor { half: HALF, @@ -131,13 +131,6 @@ pub struct MoveState { pub cd: u8, } -/// The client-predicted step of every Arena capsule (`player` kinds whose -/// zone takes a plain [`MoveInput`]: Arena, The Freeze). -#[predict(ArenaStep, kind = "player")] -pub fn arena_step(state: &MoveState, input: &MoveInput, _context: &(), _dt: f32) -> MoveState { - step(*state, *input) -} - /// Advance one command frame. pub fn step(s: MoveState, input: MoveInput) -> MoveState { canonicalize(clamp_walls(advance(s, input))) diff --git a/examples/game/server/sim/src/lib.rs b/examples/game/server/sim/src/lib.rs index 61c08915..6be307ca 100644 --- a/examples/game/server/sim/src/lib.rs +++ b/examples/game/server/sim/src/lib.rs @@ -3,19 +3,15 @@ //! (compiled to WebAssembly by `distributed game-wasm`), and the pure //! functions the browser shares with them. //! -//! Each demo that predicts movement declares its step here with -//! `#[predict]` and names it in its game surface -//! (`GameSurface::predict::()`): -//! -//! | step | kind | used by | -//! |---|---|---| -//! | [`arena::ArenaStep`] | `player` | The Freeze | -//! | [`mobpit::PitStep`] | `player` | Mob Pit | +//! Mob Pit's predicted step is declared here with `#[predict]` +//! ([`mobpit::PitStep`], kind `player`, over the planar capsule of +//! [`arena`]) and named in its game surface +//! (`GameSurface::predict::()`). //! //! The seeded world generation ([`grid`]: noise, biomes, props) feeds Mob //! Pit's rooms; [`mobpit::pit_room`] is a `#[game_pure]` function, so the //! browser draws the server's own rooms. Static data the pages draw is exported as -//! surface constants ([`arena::arena_floor`], [`mobpit::pit_world`]). +//! surface constants ([`mobpit::pit_world`]). //! //! Nothing here may depend on server-only crates (Tokio, axum, SQLite): //! the crate must compile for `wasm32-unknown-unknown`. diff --git a/examples/game/server/src/common/dev.rs b/examples/game/server/src/common/dev.rs index 443d9935..56e8411d 100644 --- a/examples/game/server/src/common/dev.rs +++ b/examples/game/server/src/common/dev.rs @@ -1,49 +1,16 @@ -//! **Dev-only** HTTP routes, mounted only with `--dev` / `DG_DEMO_DEV=1`. -//! -//! - `POST /dev/ticket` `{zone, name?, role?, guest?}` → -//! `{ticket, principal, name, guest, zone, expiresAt}`. Mints a single-use -//! HS256 admission ticket for a **server-generated** guest principal. The -//! caller chooses only a display name; `guest` (a token from an earlier -//! response, HMAC-signed by this server) keeps the same principal across -//! page reloads. A real game gets tickets from its authenticated lobby. -//! - `GET /dev/netsim` / `POST /dev/netsim` `{latencyMs, jitterMs, loss}` — -//! read / replace the server-side network simulation for **every** -//! connection (one-way latency applied in both directions, so RTT grows by -//! 2 × latency; loss is a TCP-retransmit delay, see `NetSim`). +//! The dev ticket route's wire types (`POST /dev/ticket`, mounted by Mob +//! Pit's services with `--dev`): `{zone, name?, role?, guest?}` → +//! `{ticket, principal, name, guest, zone, expiresAt}`, a single-use HS256 +//! admission ticket for a **server-generated** guest principal. The caller +//! chooses only a display name; `guest` (a token from an earlier response, +//! HMAC-signed by the server) keeps the same principal across page +//! reloads. A real game gets tickets from its authenticated lobby. -use std::time::{SystemTime, UNIX_EPOCH}; - -use axum::extract::State; -use axum::http::StatusCode; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use distributed_game::net::{GameServer, NetSim, TicketKey}; -use distributed_game::{Role, TicketClaims}; use serde::{Deserialize, Serialize}; -use std::time::Duration; - -use super::guests::{self, Guests}; -use super::Demo; /// Ticket lifetime: long enough to connect, short because it is single use. pub const TICKET_TTL_SECS: u64 = 120; -struct DevState { - server: GameServer, - key: TicketKey, - guests: Guests, -} - -impl Clone for DevState { - fn clone(&self) -> Self { - Self { - server: self.server.clone(), - key: self.key.clone(), - guests: self.guests.clone(), - } - } -} - /// `POST /dev/ticket` body. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -78,119 +45,3 @@ pub struct TicketResponse { /// Unix seconds. pub expires_at: u64, } - -/// NetSim as JSON (milliseconds, loss as a fraction). -#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct NetSimJson { - /// One-way latency. - pub latency_ms: u64, - /// Uniform extra delay in `[0, jitter]`. - pub jitter_ms: u64, - /// Fraction of messages delayed by a simulated retransmit. - pub loss: f32, -} - -impl NetSimJson { - fn from_sim(sim: Option) -> Self { - sim.map_or_else(Self::default, |s| Self { - latency_ms: s.latency.as_millis() as u64, - jitter_ms: s.jitter.as_millis() as u64, - loss: s.loss, - }) - } - - fn to_sim(self) -> Option { - let latency = Duration::from_millis(self.latency_ms.min(1000)); - let jitter = Duration::from_millis(self.jitter_ms.min(500)); - let loss = if self.loss.is_finite() { - self.loss.clamp(0.0, 0.5) - } else { - 0.0 - }; - (!latency.is_zero() || !jitter.is_zero() || loss > 0.0) - .then(|| NetSim::new(latency, jitter).with_loss(loss, Duration::from_millis(200))) - } -} - -/// The dev router for demo `D`. -pub fn routes(server: GameServer, key: TicketKey, guests: Guests) -> Router { - Router::new() - .route("/dev/ticket", post(ticket::)) - .route("/dev/netsim", get(get_netsim::).post(set_netsim::)) - .with_state(DevState:: { - server, - key, - guests, - }) -} - -type ApiError = (StatusCode, Json); - -fn bad_request(message: &str) -> ApiError { - ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ "error": message })), - ) -} - -async fn ticket( - State(state): State>, - Json(request): Json, -) -> Result, ApiError> { - if !D::hosts_zone(&request.zone) { - return Err(bad_request("zone is not hosted by this demo")); - } - let role = match request.role.as_deref() { - None | Some("player") => Role::Player, - Some("observer") => Role::Observer, - Some(_) => return Err(bad_request("role must be player or observer")), - }; - // Identity: a verified guest token, else a brand-new principal. - let principal = request - .guest - .as_deref() - .and_then(|token| guests::verify_guest_token(&state.key, token)) - .unwrap_or_else(guests::new_principal); - let name = request - .name - .as_deref() - .map(guests::sanitize_name) - .filter(|n| !n.is_empty()) - .or_else(|| state.guests.name(&principal)) - .unwrap_or_else(|| format!("Guest {}", principal.get(6..10).unwrap_or(""))); - state.guests.set_name(&principal, name.clone()); - let expires_at = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, |d| d.as_secs()) - + TICKET_TTL_SECS; - let ticket = state.key.sign(&TicketClaims { - principal: principal.clone(), - zone: request.zone.clone(), - expires_at, - nonce: guests::random_hex(24), - role, - name: None, - extensions: Default::default(), - }); - Ok(Json(TicketResponse { - ticket, - guest: guests::guest_token(&state.key, &principal), - principal, - name, - zone: request.zone, - expires_at, - })) -} - -async fn get_netsim(State(state): State>) -> Json { - Json(NetSimJson::from_sim(state.server.net_sim())) -} - -async fn set_netsim( - State(state): State>, - Json(body): Json, -) -> Json { - state.server.set_net_sim(body.to_sim()); - Json(NetSimJson::from_sim(state.server.net_sim())) -} diff --git a/examples/game/server/src/common/mod.rs b/examples/game/server/src/common/mod.rs index 732c0704..475462c4 100644 --- a/examples/game/server/src/common/mod.rs +++ b/examples/game/server/src/common/mod.rs @@ -1,12 +1,7 @@ -//! Shared plumbing for every demo: CLI options, the [`Demo`] trait, server -//! startup ([`spawn`] / [`run`]), the dev-only routes ([`dev`]), guest display -//! names ([`Guests`]) and process helpers ([`shutdown_signal`], -//! [`random_hex`]). Zone events are typed (`#[derive(GameEvent)]`, sent with -//! `ctx.send` / `ctx.broadcast`) in each demo module. -//! -//! A new demo implements [`Demo`] in its own module and adds one subcommand -//! line in `main.rs`; everything else (ticket endpoint, NetSim control, -//! `/metrics`, graceful shutdown) comes from here. +//! Shared plumbing of the demo binary: CLI options ([`DemoArgs`]), the +//! dev ticket wire types ([`dev`]), guest identities ([`guests`]), celld +//! options, the outbox relay, the zone status read model and process +//! helpers ([`shutdown_signal`], [`random_hex`]). pub mod celld; pub mod dev; @@ -14,34 +9,9 @@ pub mod guests; pub mod relay; pub mod zone_status; -use std::future::Future; -use std::net::SocketAddr; - -use axum::Router; -use distributed_game::net::{GameServer, NetSim, ServerConfig, TicketKey}; -use distributed_game::Zone; - pub use celld::{CelldOptions, CELLD_URL_ENV}; pub use guests::{random_hex, Guests}; -/// One playable demo hosted by `game-demos `. -pub trait Demo: 'static { - /// The zone type. - type Zone: Zone; - /// Subcommand / URL path name (`freeze` → `/freeze/`). - const NAME: &'static str; - /// Zone the client joins by default. - const DEFAULT_ZONE: &'static str; - - /// Zones this demo creates on demand (the dev ticket endpoint refuses - /// others). - fn hosts_zone(zone_id: &str) -> bool; - - /// Build the server. `config` already carries the ticket key, 30 Hz - /// ticks, 20 Hz patches and any `DG_NET_*` simulation; adjust as needed. - fn server(config: ServerConfig, guests: Guests) -> GameServer; -} - /// Options shared by every demo subcommand. #[derive(Debug, Clone, clap::Args)] pub struct DemoArgs { @@ -60,151 +30,6 @@ pub struct DemoArgs { pub secret: Option, } -/// In-process startup options (tests and [`run`]). -#[derive(Clone)] -pub struct DemoOptions { - /// Ticket signing key. - pub key: TicketKey, - /// Mount the dev routes. - pub dev: bool, - /// Initial network simulation. - pub net_sim: Option, -} - -impl DemoOptions { - /// Dev routes on, the given secret, no simulation. - pub fn dev(secret: &str) -> Self { - Self { - key: TicketKey::new(secret), - dev: true, - net_sim: None, - } - } -} - -/// A demo server running on a Tokio task. -pub struct RunningDemo { - /// Bound address. - pub addr: SocketAddr, - /// The game server (inspect zones, change NetSim, shut down). - pub server: GameServer, - /// Display names registered through `/dev/ticket`. - pub guests: Guests, - /// Resolves when the server stopped. - pub task: tokio::task::JoinHandle>, -} - -impl RunningDemo { - /// `ws://addr` (append `/zones/{zone}`). - pub fn ws_url(&self) -> String { - format!("ws://{}", self.addr) - } - - /// `http://addr`. - pub fn http_url(&self) -> String { - format!("http://{}", self.addr) - } -} - -/// Routes a demo adds to its server: `dev` only with `--dev` (next to -/// `/dev/ticket`), `internal` always (each route authenticates itself, e.g. -/// Mob Pit's secret-protected celld Queue relay). -#[derive(Default)] -pub struct DemoRoutes { - /// Dev-only routes. - pub dev: Router, - /// Always-mounted internal routes. - pub internal: Router, -} - -/// Server config every demo starts from. -pub fn server_config(key: TicketKey, net_sim: Option) -> ServerConfig { - let mut config = ServerConfig::new(key); - config.host.tick_rate = 30; - config.host.patch_rate = 20; - config.net_sim = net_sim; - config -} - -/// Start demo `D` on `listener` until `signal` resolves. -pub fn spawn( - listener: tokio::net::TcpListener, - options: DemoOptions, - signal: impl Future + Send + 'static, -) -> std::io::Result> { - spawn_with::( - listener, - options, - |config, guests| (D::server(config, guests), DemoRoutes::default()), - signal, - ) -} - -/// Like [`spawn`], but `build` makes the server and the demo's own routes -/// ([`DemoRoutes`]). For demos whose zones and routes share state (e.g. a -/// durable store) built by the caller. -pub fn spawn_with( - listener: tokio::net::TcpListener, - options: DemoOptions, - build: impl FnOnce(ServerConfig, Guests) -> (GameServer, DemoRoutes), - signal: impl Future + Send + 'static, -) -> std::io::Result> { - let addr = listener.local_addr()?; - let guests = Guests::default(); - let (server, routes) = build( - server_config(options.key.clone(), options.net_sim), - guests.clone(), - ); - let extra = if options.dev { - dev::routes::(server.clone(), options.key, guests.clone()) - .merge(routes.dev) - .merge(routes.internal) - } else { - routes.internal - }; - let task = tokio::spawn(server.clone().serve_with(listener, extra, signal)); - Ok(RunningDemo { - addr, - server, - guests, - task, - }) -} - -/// CLI entry: bind, print the address, serve until Ctrl-C / SIGTERM. -pub async fn run(args: DemoArgs) -> std::io::Result<()> { - run_with::(args, |config, guests| { - (D::server(config, guests), DemoRoutes::default()) - }) - .await -} - -/// [`run`] with a caller-built server and routes (see [`spawn_with`]). -pub async fn run_with( - args: DemoArgs, - build: impl FnOnce(ServerConfig, Guests) -> (GameServer, DemoRoutes), -) -> std::io::Result<()> { - let listener = tokio::net::TcpListener::bind((args.host.as_str(), args.port)).await?; - let secret = args.secret.unwrap_or_else(|| guests::random_hex(32)); - let options = DemoOptions { - key: TicketKey::new(secret), - dev: args.dev, - net_sim: NetSim::from_env(), - }; - let running = spawn_with::(listener, options, build, shutdown_signal())?; - // Parsed by run.sh and the client tests: keep the format. - println!("LISTENING http://{}", running.addr); - println!( - "demo {} · zone {} · ws {}/zones/{} · dev routes {}", - D::NAME, - D::DEFAULT_ZONE, - running.ws_url(), - D::DEFAULT_ZONE, - if args.dev { "ON" } else { "off" } - ); - running.task.await.map_err(std::io::Error::other)? -} - /// Resolves on Ctrl-C or SIGTERM (graceful shutdown for every demo binary). pub async fn shutdown_signal() { let ctrl_c = async { diff --git a/examples/game/server/src/lib.rs b/examples/game/server/src/lib.rs index 6bba7f57..962dc990 100644 --- a/examples/game/server/src/lib.rs +++ b/examples/game/server/src/lib.rs @@ -14,6 +14,5 @@ pub mod common; pub mod devcelld; -pub mod freeze; pub mod mobpit; pub mod predict_trace; diff --git a/examples/game/server/src/main.rs b/examples/game/server/src/main.rs index 56f16883..6c5ddcf2 100644 --- a/examples/game/server/src/main.rs +++ b/examples/game/server/src/main.rs @@ -1,7 +1,6 @@ //! `game-demos [--port N] [--dev]` — one subcommand per demo. use clap::{Parser, Subcommand}; -use game_demos::freeze::world::FreezeArgs; use game_demos::mobpit::{self, MobPitArgs}; #[derive(Parser)] @@ -20,8 +19,6 @@ enum DemoCommand { Mobpit(MobPitArgs), /// Kills/XP leaderboards (same server as `mobpit`). Leaderboard(MobPitArgs), - /// World-version barrier across two server processes, safe-room reloads. - Freeze(FreezeArgs), /// Print native traces of every predicted step over recorded input /// streams (JSON; the WASM exact-match test replays them). #[command(hide = true)] @@ -32,7 +29,6 @@ enum DemoCommand { async fn main() -> std::io::Result<()> { match Cli::parse().demo { DemoCommand::Mobpit(args) | DemoCommand::Leaderboard(args) => mobpit::run(args).await, - DemoCommand::Freeze(args) => game_demos::freeze::world::run(args).await, DemoCommand::PredictTrace => { println!("{}", game_demos::predict_trace::traces()); Ok(()) diff --git a/examples/game/server/src/mobpit/mod.rs b/examples/game/server/src/mobpit/mod.rs index 7e826d73..b1182995 100644 --- a/examples/game/server/src/mobpit/mod.rs +++ b/examples/game/server/src/mobpit/mod.rs @@ -75,6 +75,10 @@ pub struct MobPitArgs { /// Default: `sqlite:{data-dir}/bus.db`. #[arg(long, env = "MOBPIT_PROJECTION_BUS")] pub projection_bus: Option, + /// Admin secret of the dev admin routes (`/dev/mobpit/rollout`, + /// header `x-game-admin-secret`). Default: random, printed at startup. + #[arg(long, env = "DG_DEMO_ADMIN_SECRET", hide_env_values = true)] + pub admin_secret: Option, } /// The services running on a Tokio task. @@ -98,13 +102,14 @@ pub fn spawn( listener: tokio::net::TcpListener, key: TicketKey, dev: bool, + admin_secret: String, world: Arc, signal: impl Future + Send + 'static, ) -> std::io::Result { let addr = listener.local_addr()?; let mut app = relay_routes(world.celld().secret.clone(), world.projection()); if dev { - app = app.merge(routes::routes(world, key)); + app = app.merge(routes::routes(world, key, admin_secret)); } let task = tokio::spawn(async move { axum::serve(listener, app) @@ -168,10 +173,16 @@ pub async fn run(args: MobPitArgs) -> std::io::Result<()> { } let listener = tokio::net::TcpListener::bind((args.common.host.as_str(), args.common.port)).await?; + let admin_secret = args + .admin_secret + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| random_hex(24)); let running = spawn( listener, TicketKey::new(ticket_secret), args.common.dev, + admin_secret.clone(), world.clone(), shutdown_signal(), )?; @@ -185,5 +196,8 @@ pub async fn run(args: MobPitArgs) -> std::io::Result<()> { world.board().backend(), if args.common.dev { "ON" } else { "off" } ); + if args.common.dev { + println!("admin secret (live content updates, ?admin=…): {admin_secret}"); + } running.task.await.map_err(std::io::Error::other)? } diff --git a/examples/game/server/src/mobpit/routes.rs b/examples/game/server/src/mobpit/routes.rs index d0edbf05..3d29a5da 100644 --- a/examples/game/server/src/mobpit/routes.rs +++ b/examples/game/server/src/mobpit/routes.rs @@ -27,6 +27,15 @@ //! read / replace the network simulation of **every** room (dev settings //! object on celld, read by each room within a second; applied per //! socket in both directions, so the RTT grows by 2 × latency). +//! - `GET /dev/mobpit/content` — the world's live content (`content-rollout/ +//! world`: version, phase, notes) and every content pack. +//! - `POST /dev/mobpit/rollout` `{version, notes?, hard?, freezeMs?}` — +//! **admin** (`x-game-admin-secret`, the services' admin secret): a live +//! content update of every room. The rollout cell freezes the rooms +//! (change notes on screen), applies the pack and resumes them on its own +//! schedule. Only known packs (`mobpit_rooms::content_packs`). The cell +//! runs it for the services' admin session (`admin:dev`), never for a +//! principal from the body. use std::collections::HashMap; use std::sync::Arc; @@ -62,10 +71,17 @@ fn bad_request(message: &str) -> (StatusCode, Json) { struct Dev { world: Arc, key: TicketKey, + admin_secret: Arc, } -/// The router. -pub fn routes(world: Arc, key: TicketKey) -> Router { +/// The world whose content the rooms follow (`ZONE_CONTENT_ROLLOUT` in the +/// cells worker's `wrangler.jsonc`). +pub const CONTENT_WORLD: &str = "world"; +/// The services' admin session principal. +const ADMIN_PRINCIPAL: &str = "admin:dev"; + +/// The router. `admin_secret` guards the admin routes (rollouts). +pub fn routes(world: Arc, key: TicketKey, admin_secret: String) -> Router { Router::new() .route("/dev/ticket", post(ticket)) .route("/dev/mobpit/start", post(start)) @@ -77,7 +93,13 @@ pub fn routes(world: Arc, key: TicketKey) -> Router { .route("/dev/mobpit/presence", get(presence)) .route("/dev/mobpit/tiers", get(tiers)) .route("/dev/netsim", get(get_netsim).post(set_netsim)) - .with_state(Dev { world, key }) + .route("/dev/mobpit/content", get(content)) + .route("/dev/mobpit/rollout", post(rollout)) + .with_state(Dev { + world, + key, + admin_secret: admin_secret.into(), + }) } async fn ticket( @@ -288,3 +310,105 @@ async fn tiers(State(dev): State, Query(query): Query Value { + Value::Array( + mobpit_rooms::content_packs() + .into_iter() + .map(|p| { + json!({ + "version": p.version, "name": p.name, "mobPace": p.mob_pace, + "mobHpPct": p.mob_hp_pct, "tint": p.tint, "notes": p.notes, + }) + }) + .collect(), + ) +} + +async fn content(State(dev): State) -> ApiResult { + let state = dev + .world + .cells() + .content(CONTENT_WORLD) + .await + .map_err(internal)?; + Ok(Json( + json!({ "world": CONTENT_WORLD, "state": state, "packs": packs_json() }), + )) +} + +/// `POST /dev/mobpit/rollout` body. +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct RolloutRequest { + version: String, + #[serde(default)] + notes: Option, + #[serde(default)] + hard: bool, + #[serde(default)] + freeze_ms: Option, +} + +async fn rollout( + State(dev): State, + headers: axum::http::HeaderMap, + Json(request): Json, +) -> ApiResult { + let given = headers + .get(distributed_game::net::peer::ADMIN_SECRET_HEADER) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + if dev.admin_secret.is_empty() + || !constant_time_eq(given.as_bytes(), dev.admin_secret.as_bytes()) + { + return Err(( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "admin secret required (x-game-admin-secret)" })), + )); + } + let Some(pack) = mobpit_rooms::content_packs() + .into_iter() + .find(|p| p.version == request.version) + else { + return Err(bad_request("unknown content version")); + }; + let notes = request + .notes + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| pack.notes.clone()); + let input = json!({ + "world": CONTENT_WORLD, + "version": pack.version, + "notes": notes, + "freeze_ms": request.freeze_ms.unwrap_or(3_000), + "hard": request.hard, + }); + let (status, body) = dev + .world + .cells() + .command( + distributed_game::world::content_rollout::CONTENT_ROLLOUT_KIND, + CONTENT_WORLD, + distributed_game::world::content_rollout::START, + ADMIN_PRINCIPAL, + &input, + ) + .await + .map_err(internal)?; + if (200..300).contains(&status) { + Ok(Json( + json!({ "started": body["payload"], "version": pack.version, "notes": notes }), + )) + } else { + Err(( + StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_GATEWAY), + Json(body), + )) + } +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} diff --git a/examples/game/server/src/mobpit/store.rs b/examples/game/server/src/mobpit/store.rs index 8fb6040e..1f17da9a 100644 --- a/examples/game/server/src/mobpit/store.rs +++ b/examples/game/server/src/mobpit/store.rs @@ -164,6 +164,60 @@ impl CrawlerCells { Ok(self.get(kind, shard).await?["state"].take()) } + /// The world's content rollout (`content-rollout/{world}`: live + /// version, phase, notes; `null` before the first rollout). + pub async fn content(&self, world: &str) -> Result { + Ok(self + .get( + distributed_game::world::content_rollout::CONTENT_ROLLOUT_KIND, + world, + ) + .await?["state"] + .take()) + } + + /// Run `command` on the cell `kind/shard` as `principal` (an internal + /// session the caller vouches for, e.g. an admin it authenticated). + /// Returns the HTTP status and the cell's answer. + pub async fn command( + &self, + kind: &str, + shard: &str, + command: &str, + principal: &str, + input: &Value, + ) -> Result<(u16, Value), String> { + let url = format!( + "{}/{}/{}/{}", + self.base, + kind, + encode_segment(shard), + command + ); + let command_id = + distributed_game::cell::command_id_for("mobpit-admin", &crate::common::random_hex(16)); + let response = self + .client + .post(&url) + .header(CELL_INTERNAL_SECRET_HEADER, self.secret.header_value()) + .header( + distributed::cell_host::CELL_SERVICE_ID_HEADER, + "mobpit-admin", + ) + .header( + distributed::cell_host::CELL_PRINCIPAL_PARTITION_HEADER, + principal, + ) + .header(distributed::USER_ID_KEY, principal) + .json(&serde_json::json!({ "commandId": command_id, "input": input })) + .send() + .await + .map_err(|error| format!("celld unreachable at {url}: {error}"))?; + let status = response.status().as_u16(); + let body: Value = response.json().await.unwrap_or(Value::Null); + Ok((status, body)) + } + /// The durable presence entry of `principal` (`null` when none). pub async fn presence(&self, principal: &str) -> Result { Ok(self.get(PRESENCE_KIND, principal).await?["current"].take()) diff --git a/examples/game/server/src/predict_trace.rs b/examples/game/server/src/predict_trace.rs index a2f8a3ba..fe070689 100644 --- a/examples/game/server/src/predict_trace.rs +++ b/examples/game/server/src/predict_trace.rs @@ -15,7 +15,7 @@ use distributed_game::prediction::{step_bytes, PredictContext, PredictStep}; use distributed_game::pure::{call_bytes, GamePure}; use distributed_game::Wire; -use game_sim::arena::{self, ArenaStep, MoveState}; +use game_sim::arena; use game_sim::mobpit::PitStep; use serde_json::{json, Value}; @@ -127,37 +127,6 @@ fn random(seed: u32, frames: usize, extra_bytes: usize) -> Vec> { out } -fn capsule_streams(extra_bytes: usize) -> Vec { - let start = |x: f32, y: f32| { - state_bytes(&arena::canonicalize(MoveState { - x, - y, - ..MoveState::default() - })) - }; - let extra = move |i: usize| vec![(i as u8).wrapping_mul(37); extra_bytes]; - vec![ - Stream { - name: "scripted".into(), - start: start(-5.0, -5.0), - context: Vec::new(), - inputs: scripted(&extra), - }, - Stream { - name: "random".into(), - start: start(8.0, 3.0), - context: Vec::new(), - inputs: random(0x1234_5678, 3000, extra_bytes), - }, - Stream { - name: "corner".into(), - start: start(18.5, -18.5), - context: Vec::new(), - inputs: random(0x0bad_cafe, 600, extra_bytes), - }, - ] -} - /// Mob Pit's step in the entrance (the classic pillars) and in rooms of /// other seeded patterns (the context names the room; its obstacles are /// generated inside the step). @@ -301,7 +270,6 @@ pub fn traces() -> Value { pure_trace::(pit_room_args()), ], "steps": [ - trace::(capsule_streams(0)), trace::(pit_streams()), ], }) @@ -315,7 +283,7 @@ mod tests { fn traces_cover_every_step_and_collide() { let traces = traces(); let steps = traces["steps"].as_array().unwrap(); - assert_eq!(steps.len(), 2); + assert_eq!(steps.len(), 1); for step in steps { for stream in step["streams"].as_array().unwrap() { assert_eq!( diff --git a/examples/game/server/tests/mobpit_smoke.rs b/examples/game/server/tests/mobpit_smoke.rs index 4f24887a..57b72193 100644 --- a/examples/game/server/tests/mobpit_smoke.rs +++ b/examples/game/server/tests/mobpit_smoke.rs @@ -38,6 +38,9 @@ use game_demos::mobpit::{self, encode_bot_input, MobPitWorld, RunningServices, W use serde_json::{json, Value}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; +/// The services' admin secret in these tests. +const ADMIN_SECRET: &str = "smoke-admin-secret"; + /// The rooms' empty → hibernated delay in these tests. const HIBERNATE_AFTER_MS: &str = "1500"; @@ -309,6 +312,7 @@ async fn start( listener, TicketKey::new(celld.ticket_secret()), true, + ADMIN_SECRET.to_string(), world, async move { let _ = stopped.await; diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts b/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts index 0d224eb4..9ce9e4dd 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts @@ -1,7 +1,31 @@ // @generated by `distributed game-client` from game surface `mobpit` (schema v3, protocol v1). // Constants: static data defined once in Rust (`GameSurface::constant`). // Do not edit: change the Rust definitions and regenerate. -import type { PitRules, PitWorld } from './events.ts'; +import type { ContentPack, PitRules, PitWorld } from './events.ts'; + +/** `content_packs`, as the client decodes it. */ +export const CONTENT_PACKS: readonly ContentPack[] = [ + { version: '', name: 'Classic', mobPace: 1, mobHpPct: 100, tint: 0, notes: '' }, + { + version: 'frostfall', + name: 'Frostfall', + mobPace: 0.699999988079071, + mobHpPct: 120, + tint: 9425919, + notes: 'Frostfall: the mobs are frozen stiff (30 % slower) but tougher (+20 % HP).' + }, + { + version: 'emberfall', + name: 'Emberfall', + mobPace: 1.25, + mobHpPct: 85, + tint: 16742970, + notes: 'Emberfall: the mobs burn hot (25 % faster) and brittle (-15 % HP).' + } +]; + +/** `gate_room`, as the client decodes it. */ +export const GATE_ROOM: string = 'pit.0.0'; /** `patterns`, as the client decodes it. */ export const PATTERNS: readonly string[] = ['pillars', 'colonnade', 'ring', 'rubble', 'hall', 'grove']; diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/events.ts b/examples/game/sveltekit/src/lib/generated/mobpit/events.ts index d42a4f85..900ad093 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/events.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/events.ts @@ -3,6 +3,16 @@ // Do not edit: change the Rust definitions and regenerate. import { decodeGameEvent, decodePayload, decodeValue, encodePayload, encodeValue, onGameEvent, type DecodedGameEvent, type GameEventCodecs, type GameEventSource, type PayloadLayout, type WireType } from '@hops-ops/distributed/game'; +/** Record `ContentPack` (`#[derive(GameRecord)]`). */ +export interface ContentPack { + readonly version: string; + readonly name: string; + readonly mobPace: number; + readonly mobHpPct: number; + readonly tint: number; + readonly notes: string; +} + /** Record `MobTier` (`#[derive(GameRecord)]`). */ export interface MobTier { readonly tier: number; @@ -288,6 +298,17 @@ const SHOT_LAYOUT: PayloadLayout = [ ['z', { type: 'f32' }], ]; +/** Event `stale` (id 10). */ +export interface StaleEvent { + readonly gate: string; + readonly version: string; +} + +const STALE_LAYOUT: PayloadLayout = [ + ['gate', { type: 'short_str' }], + ['version', { type: 'short_str' }], +]; + /** Message `loaded` (id 1). */ export interface LoadedMessage { readonly principal: string; @@ -323,6 +344,7 @@ export const EVENT = { world: 7, bossDown: 8, shot: 9, + stale: 10, } as const; /** Event payload types by name. */ @@ -335,6 +357,7 @@ export interface Events { world: WorldEvent; bossDown: BossDownEvent; shot: ShotEvent; + stale: StaleEvent; } /** Codecs of every event. */ @@ -387,6 +410,12 @@ export const EVENTS: GameEventCodecs = { encode: (value) => encodePayload(SHOT_LAYOUT, value), decode: (payload) => decodePayload(SHOT_LAYOUT, payload) as unknown as ShotEvent }, + stale: { + id: 10, + name: 'stale', + encode: (value) => encodePayload(STALE_LAYOUT, value), + decode: (payload) => decodePayload(STALE_LAYOUT, payload) as unknown as StaleEvent + }, }; /** A decoded event, discriminated by `name`. */ diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/surface.json b/examples/game/sveltekit/src/lib/generated/mobpit/surface.json index 19f49d99..7eb9583d 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/surface.json +++ b/examples/game/sveltekit/src/lib/generated/mobpit/surface.json @@ -1,5 +1,48 @@ { "constants": [ + { + "name": "content_packs", + "type": { + "item": { + "name": "ContentPack", + "type": "record" + }, + "type": "list" + }, + "value": [ + { + "mob_hp_pct": 100, + "mob_pace": 1.0, + "name": "Classic", + "notes": "", + "tint": 0, + "version": "" + }, + { + "mob_hp_pct": 120, + "mob_pace": 0.699999988079071, + "name": "Frostfall", + "notes": "Frostfall: the mobs are frozen stiff (30 % slower) but tougher (+20 % HP).", + "tint": 9425919, + "version": "frostfall" + }, + { + "mob_hp_pct": 85, + "mob_pace": 1.25, + "name": "Emberfall", + "notes": "Emberfall: the mobs burn hot (25 % faster) and brittle (-15 % HP).", + "tint": 16742970, + "version": "emberfall" + } + ] + }, + { + "name": "gate_room", + "type": { + "type": "short_str" + }, + "value": "pit.0.0" + }, { "name": "patterns", "type": { @@ -486,6 +529,27 @@ ], "kind": "fields" } + }, + { + "id": 10, + "name": "stale", + "payload": { + "fields": [ + { + "name": "gate", + "type": { + "type": "short_str" + } + }, + { + "name": "version", + "type": { + "type": "short_str" + } + } + ], + "kind": "fields" + } } ], "input": { @@ -977,6 +1041,47 @@ } ], "records": [ + { + "fields": [ + { + "name": "version", + "type": { + "type": "short_str" + } + }, + { + "name": "name", + "type": { + "type": "short_str" + } + }, + { + "name": "mob_pace", + "type": { + "type": "f32" + } + }, + { + "name": "mob_hp_pct", + "type": { + "type": "u16" + } + }, + { + "name": "tint", + "type": { + "type": "u32" + } + }, + { + "name": "notes", + "type": { + "type": "short_str" + } + } + ], + "name": "ContentPack" + }, { "fields": [ { diff --git a/examples/game/sveltekit/src/lib/pit/loop.ts b/examples/game/sveltekit/src/lib/pit/loop.ts index a77c9068..dc376ac6 100644 --- a/examples/game/sveltekit/src/lib/pit/loop.ts +++ b/examples/game/sveltekit/src/lib/pit/loop.ts @@ -96,8 +96,18 @@ export type PitLoopOptions = Readonly<{ firstPerson?: () => boolean; /** Resolve arrows with lag compensation (default on). */ compensated?: () => boolean; + /** The tint (0xRRGGBB, 0: none) of the content pack this client runs. */ + tint?: () => number; }>; +/** `color` blended 60 % towards `tint` (0: unchanged). */ +function tinted(color: string, tint: number): string { + if (tint === 0) return color; + const c = Number.parseInt(color.slice(1), 16); + const mix = (shift: number) => Math.round(((c >> shift) & 255) * 0.4 + ((tint >> shift) & 255) * 0.6); + return `#${((mix(16) << 16) | (mix(8) << 8) | mix(0)).toString(16).padStart(6, '0')}`; +} + /** Entry distance of a ray (unit `dir`) into a vertical capsule on the floor. */ function rayCapsule(origin: number[], dir: number[], x: number, y: number, radius: number, height: number): number | undefined { const flat = Math.hypot(dir[0]!, dir[1]!); @@ -426,7 +436,7 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) const m = mobFields(entity); const look = TIER_RADIUS[m.tier]; if (m.tele > 0 && look !== undefined) circle(w.x, w.y, look.tele, 'rgba(255,80,80,0.12)', '#ff5050'); - circle(w.x, w.y, look?.radius ?? 0.5, TIER_COLOR[m.tier] ?? '#9ad16b'); + circle(w.x, w.y, look?.radius ?? 0.5, tinted(TIER_COLOR[m.tier] ?? '#9ad16b', options.tint?.() ?? 0)); } else if (isPlayer(entity)) { if (entity.stale) continue; const p = playerFields(entity); diff --git a/examples/game/sveltekit/src/lib/server/game.ts b/examples/game/sveltekit/src/lib/server/game.ts index eb703fbd..3a94872c 100644 --- a/examples/game/sveltekit/src/lib/server/game.ts +++ b/examples/game/sveltekit/src/lib/server/game.ts @@ -36,6 +36,38 @@ export async function lastRoom(principal: string): Promise { } } +/** The world whose content the rooms follow (`ZONE_CONTENT_ROLLOUT`). */ +export const CONTENT_WORLD = 'world'; + +/** The world's content rollout cell (`content-rollout/world`) as celld reports it. */ +export type ContentState = { version: string; target: string; phase: string; notes: string; since_ms: number; hard: boolean }; + +/** + * celld's internal path for a cell (`CELLD_URL`, `DISTRIBUTED_INTERNAL_SECRET`; + * server-only). Undefined when celld is not configured. + */ +export async function cellFetch(path: string, init: RequestInit = {}): Promise { + const base = env.CELLD_URL; + const secret = env.DISTRIBUTED_INTERNAL_SECRET; + if (!base || !secret) return undefined; + return fetch(`${base.replace(/\/$/, '')}${path}`, { + ...init, + headers: { ...(init.headers as Record | undefined), 'x-distributed-internal-secret': secret }, + signal: AbortSignal.timeout(3000) + }); +} + +/** The world's live content (null before the first rollout or when celld is unreachable). */ +export async function liveContent(): Promise { + try { + const response = await cellFetch(`/content-rollout/${CONTENT_WORLD}`); + if (response === undefined || !response.ok) return null; + return ((await response.json()) as { state?: ContentState | null }).state ?? null; + } catch { + return null; + } +} + /** * Room admission: principal from the Auth.js session, the room chosen here — * on a page load the room the player was last in (so a reload, a new tab diff --git a/examples/game/sveltekit/src/routes/api/dev/rollout/+server.ts b/examples/game/sveltekit/src/routes/api/dev/rollout/+server.ts new file mode 100644 index 00000000..90ffa1b4 --- /dev/null +++ b/examples/game/sveltekit/src/routes/api/dev/rollout/+server.ts @@ -0,0 +1,64 @@ +/** + * **Dev only** (`GAME_DEV_AUTH=1`, signed in): a live content update of + * every Mob Pit room. `GET` → the world's rollout state and the packs; + * `POST {version, notes?, hard?}` starts a rollout on the world's + * `content-rollout` cell, which freezes every room (the notes on screen), + * applies the pack and resumes them on its own schedule. The cell runs it + * for an admin session built here from the verified sign-in + * (`admin:`), never for a principal from the body; in this dev + * demo every signed-in user is an admin. + */ +import { error, json } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import { CONTENT_PACKS } from '$lib/generated/mobpit/index.ts'; +import { cellFetch, CONTENT_WORLD, liveContent } from '$lib/server/game'; +import type { RequestHandler } from './$types'; + +async function admin(locals: App.Locals): Promise { + if (env.GAME_DEV_AUTH !== '1') error(404, 'not found'); + const user = (await locals.auth())?.user; + const id = user?.id ?? user?.name; + if (!id) error(401, 'sign in first'); + return `admin:${id}`; +} + +export const GET: RequestHandler = async ({ locals }) => { + await admin(locals); + return json({ world: CONTENT_WORLD, state: await liveContent(), packs: CONTENT_PACKS }); +}; + +export const POST: RequestHandler = async ({ locals, request }) => { + const principal = await admin(locals); + const body = (await request.json()) as { version?: unknown; notes?: unknown; hard?: unknown }; + const pack = CONTENT_PACKS.find((p) => p.version === body.version); + if (pack === undefined) error(400, 'unknown content version'); + const notes = typeof body.notes === 'string' && body.notes.trim() !== '' ? body.notes.trim().slice(0, 500) : pack.notes; + const response = await cellFetch(`/content-rollout/${CONTENT_WORLD}/content_rollout.start`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-distributed-service-id': 'mobpit-admin', + 'x-distributed-principal-partition': principal, + 'x-user-id': principal + }, + body: JSON.stringify({ + commandId: commandId(), + input: { world: CONTENT_WORLD, version: pack.version, notes, freeze_ms: 3000, hard: body.hard === true } + }) + }); + if (response === undefined) error(503, 'celld is not configured'); + const answer = await response.json().catch(() => ({})); + if (!response.ok) return json(answer, { status: response.status }); + return json({ version: pack.version, notes }); +}; + +/** A UUIDv7 command id (the cells' command ledger requires v7). */ +function commandId(): string { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + const ms = Date.now(); + for (let i = 0; i < 6; i += 1) bytes[i] = Math.floor(ms / 2 ** (8 * (5 - i))) & 0xff; + bytes[6] = (bytes[6]! & 0x0f) | 0x70; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} diff --git a/examples/game/sveltekit/src/routes/api/game/content/+server.ts b/examples/game/sveltekit/src/routes/api/game/content/+server.ts new file mode 100644 index 00000000..f152c988 --- /dev/null +++ b/examples/game/sveltekit/src/routes/api/game/content/+server.ts @@ -0,0 +1,13 @@ +/** + * The world's live content version (signed in): what a client reloads to + * when a room told it its content is out of date without naming a version + * (a fresh join on old content). + */ +import { error, json } from '@sveltejs/kit'; +import { liveContent } from '$lib/server/game'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ locals }) => { + if (!(await locals.auth())?.user) error(401, 'sign in first'); + return json({ version: (await liveContent())?.version ?? '' }); +}; diff --git a/examples/game/sveltekit/src/routes/pit/+page.server.ts b/examples/game/sveltekit/src/routes/pit/+page.server.ts index bdc48211..26c58050 100644 --- a/examples/game/sveltekit/src/routes/pit/+page.server.ts +++ b/examples/game/sveltekit/src/routes/pit/+page.server.ts @@ -1,7 +1,7 @@ import { redirect } from '@sveltejs/kit'; import { loadDistributedLeaderboard } from '@hops-ops/distributed/sveltekit'; import { XpLeaderboardLeaderboard, type XpLeaderboardData } from '$distributed'; -import { roomsUrls, tickets } from '$lib/server/game'; +import { liveContent, roomsUrls, tickets } from '$lib/server/game'; import { graphqlUrl } from '$lib/server/distributed'; export const load = async (event) => { @@ -20,8 +20,11 @@ export const load = async (event) => { } catch (error) { console.error(`leaderboard SSR: ${error}`); } + // The client starts on the world's live content (its packs are built in). + const content = await liveContent(); return { zone: joined.zone, + contentVersion: content?.version ?? '', roomsUrls: roomsUrls(), principal: joined.principal, board diff --git a/examples/game/sveltekit/src/routes/pit/+page.svelte b/examples/game/sveltekit/src/routes/pit/+page.svelte index 0a99d2d0..8536e3c4 100644 --- a/examples/game/sveltekit/src/routes/pit/+page.svelte +++ b/examples/game/sveltekit/src/routes/pit/+page.svelte @@ -1,7 +1,8 @@