diff --git a/distributed_cli/src/game_client.rs b/distributed_cli/src/game_client.rs index af0bb32c1..d7c9909c8 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 9cfc8648b..9cdb565f3 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 892008aaa..d3d7c3a39 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/Cargo.toml b/distributed_game/Cargo.toml index 198ed9eab..898796225 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 568c8a6c7..276140542 100644 --- a/distributed_game/PROTOCOL.md +++ b/distributed_game/PROTOCOL.md @@ -194,6 +194,8 @@ replication priorities are not transmitted. | `0x02` | C→S | Input | | `0x03` | C→S | Ping | | `0x04` | C→S | Leave | +| `0x05` | C→S | Content | +| `0x06` | C→S | Near | | `0x80` | S→C | Welcome | | `0x81` | S→C | Delta | | `0x82` | S→C | Event | @@ -212,10 +214,13 @@ decode errors. ``` Hello := 0x01, varuint protocol_version, string content_version, - varuint capabilities, string ticket, string reconnect_token + varuint capabilities, string ticket, string reconnect_token, + bool near Input := 0x02, varuint seq, varuint client_tick, bytes payload Ping := 0x03, f64 client_time Leave := 0x04 +Content := 0x05, string content_version +Near := 0x06, bool near ``` - **Hello** MUST be the first message, sent within the server's hello @@ -225,6 +230,7 @@ Leave := 0x04 join `ticket` is a signed admission ticket (§8) and `reconnect_token` is empty; for a reconnect `ticket` MAY be empty and `reconnect_token` is the token from the last Welcome. A second Hello gets `Error(BAD_MESSAGE)`. + `near` is for observers (ignored for players): see **Near**. - **Input**: `payload` is game-defined. Duplicate or already-processed `seq` values are ignored. Inputs are rate limited per connection (default 120/s, burst 240); excess inputs are dropped and the server @@ -239,6 +245,22 @@ 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. +- **Near** (and Hello `near`): an observer says it is near the zone (e.g. + its player is close to this room) or no longer is. On zones hosted as + celld objects a near observer whose ticket grants `wake` (§8) counts as + occupancy: its Hello wakes a sleeping zone (instead of `ASLEEP`) and the + zone keeps ticking while it stays near; `near = false` (or closing the + connection) lets the zone go dormant and hibernate as usual. Without the + grant the observer stays a passive watcher and is sent + `Error(WAKE_DENIED)` (the connection stays open). + Servers that keep observed zones running anyway (the native `net` + server) ignore it; players' are ignored. Worked example — Hello (`version 1, content "c1", caps 5, ticket "t.k.n"`): @@ -249,6 +271,7 @@ Worked example — Hello (`version 1, content "c1", caps 5, ticket "t.k.n"`): 05 capabilities 5 05 74 2e 6b 2e 6e ticket "t.k.n" 00 reconnect_token "" +00 near false ``` Input (`seq 300, client_tick 1000, payload 01 ff`): `02 ac02 e807 02 01ff`. @@ -309,8 +332,13 @@ 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; - `BAD_MESSAGE` and `RATE_LIMITED` after joining are not. + 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 unless near with a `wake` grant; retry + later), `11` WAKE_DENIED (an observer said it is near but its ticket + does not grant `wake`; it stays a passive watcher — a fresh ticket may + grant it). Errors during the handshake are followed by close; + `BAD_MESSAGE`, `RATE_LIMITED` and `WAKE_DENIED` after joining are not. Worked example — Welcome (session "s1", zone "z1", player, 30 Hz ticks, 20 Hz patches, tick 42, time 1000.25 ms, token "rt", content "c1", @@ -456,6 +484,11 @@ unpadded base64url). Any HS256 JWT library can mint one. inputs. - `name` (string, optional) — display name vouched for by the issuer (never identity). Carried into transfer tickets. + - `wake` (bool, optional, default false) — observers only: the issuer + lets this watcher wake the zone and keep it awake while it is near + (Hello `near` / `Near`, celld zones). The issuer decides who may (for + example only rooms next to the principal's own); the zone never + trusts the client for it. - Failures produce `Error(UNAUTHORIZED)` and close. **Custom ticket formats.** A server MAY admit join tickets in another @@ -553,7 +586,7 @@ JSON conventions in `decoded`: ## 10. Versioning `protocol_version` changes on any incompatible wire change. Message type -bytes `0x05..0x7f` and `0x8a..0xff`, and field type tags `10..255`, are +bytes `0x07..0x7f` and `0x8a..0xff`, and field type tags `10..255`, are reserved for future versions. Additive changes within version 1 (clients SHOULD ignore unknown kick and @@ -562,6 +595,13 @@ 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. +- Error code `11` WAKE_DENIED. +- `0x06 Near` (client → server) and the ticket claim `wake`. + +Not additive (made before the first release, so the version stays 1): +Hello's trailing `bool near` — clients built before it send one byte +less and are refused with `BAD_MESSAGE`; rebuild them. - Transfer tickets (§8.1) and content-version policies (§8.2); `Handoff` and `Freeze`/`Resume` encodings are unchanged. diff --git a/distributed_game/README.md b/distributed_game/README.md index b76fba409..2e9b15548 100644 --- a/distributed_game/README.md +++ b/distributed_game/README.md @@ -95,18 +95,15 @@ 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); the rooms next door seen through passive watchers (the doorway rooms in reach woken by near 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 | -| 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. @@ -402,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 @@ -1175,6 +1173,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/bots.rs b/distributed_game/src/bots.rs index d5c3ac662..c38d2ed79 100644 --- a/distributed_game/src/bots.rs +++ b/distributed_game/src/bots.rs @@ -369,6 +369,7 @@ async fn drive( role: Role::Player, name: None, extensions: Default::default(), + wake: false, }); let url = format!( "{}/{}/{}", @@ -385,6 +386,7 @@ async fn drive( capabilities: 0, ticket, reconnect_token: String::new(), + near: false, }); ws.send(Message::Binary(hello.encode().into())) .await diff --git a/distributed_game/src/celld_zone/cells.rs b/distributed_game/src/celld_zone/cells.rs index b03f7bce5..34ef151d0 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 @@ -187,3 +188,93 @@ 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), + } + } +} + +/// 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 e7b50c7ad..9e70c5512 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). @@ -55,6 +59,49 @@ //! 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 — or a *near* watcher — wakes a zone. A watcher joining +//! a dormant zone gets its current state at once +//! ([`ZoneHost::patch_now`](crate::ZoneHost::patch_now)). +//! +//! **Near watchers** (proximity wake): an observer whose ticket grants +//! `wake` ([`TicketClaims::wake`](crate::TicketClaims::wake)) and that says +//! it is near (Hello `near`, later +//! [`ClientMessage::Near`](crate::protocol::ClientMessage::Near), no +//! reconnect) counts as occupancy like a player: it wakes a sleeping zone +//! and keeps it ticking. When it says it is no longer near, or closes, the +//! zone goes dormant and hibernates as usual. Without the grant the +//! watcher stays passive and is told +//! [`error::WAKE_DENIED`](crate::protocol::error::WAKE_DENIED) (still +//! connected; a fresh ticket may carry the grant). The ticket issuer bounds who may wake what (e.g. only rooms +//! next to the principal's own); the zone checks only the signed grant. +//! +//! **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 +//! [`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 +115,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, + 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, + 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 9e3478529..b3c1e50fd 100644 --- a/distributed_game/src/celld_zone/object.rs +++ b/distributed_game/src/celld_zone/object.rs @@ -1,10 +1,10 @@ //! The Durable Object side of the celld zone host (wasm32 only). use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; 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,13 +32,17 @@ use crate::facts::{ }; use crate::handoff::{HandoffRequest, Transfer}; use crate::inbox::ZoneInbound; -use crate::protocol::{error, ClientMessage, Role, ServerMessage, PROTOCOL_VERSION}; +use crate::netsim::{DelayLine, NetSim, NetSimJson}; +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}; /// A future that runs on the object's (single) thread. @@ -56,6 +60,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). @@ -87,6 +109,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 @@ -370,6 +411,10 @@ struct Inner { wakes: u64, state: RoomState, sockets: HashMap, + /// Observers whose ticket grants `wake` ([`TicketClaims::wake`]). + may_wake: HashSet, + /// Of those, the ones near right now: they keep the zone awake. + near: HashSet, ticking: bool, nonces: NonceCache, sink: Option, @@ -381,31 +426,187 @@ 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, + /// 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, } 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.forget(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 and nearness. + fn forget(&mut self, client: u32) { + self.out_lines.remove(&client); + self.in_lines.remove(&client); + self.may_wake.remove(&client); + self.near.remove(&client); + } + + /// Players keep a zone awake (also inside their reconnect grace), and + /// so do near observers; other observers are passive watchers. 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 || self.near.iter().any(|c| self.sockets.contains_key(c)) + }) + } + + /// An observer declares itself near (or not). Only an observer whose + /// ticket grants `wake` can be near (others are told `WAKE_DENIED`); + /// returns whether the zone must start ticking (it was not and is + /// occupied now). + fn set_near(&mut self, client: u32, near: bool) -> bool { + if !self.sockets.contains_key(&client) { + return false; + } + if near && self.may_wake.contains(&client) { + self.near.insert(client); + } else { + self.near.remove(&client); + if near { + if let Ok(frame) = (ServerMessage::Error { + code: error::WAKE_DENIED, + message: "ticket does not grant wake".into(), + }) + .encode(&()) + { + self.send_out(client, OutItem::Frame(frame)); + } + } + } + self.occupied() && !self.ticking } fn flush(&mut self) { 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), } } } @@ -523,6 +724,8 @@ impl ZoneObject { wakes: 0, state: RoomState::Cold, sockets: HashMap::new(), + may_wake: HashSet::new(), + near: HashSet::new(), ticking: false, nonces: NonceCache::default(), sink: None, @@ -534,11 +737,25 @@ 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, + last_content_read_ms: 0.0, + refresh_round: 0, + last_refresh_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, } } @@ -571,7 +788,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) @@ -616,17 +833,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() @@ -642,10 +868,23 @@ impl ZoneObject { let _ = ws.send_with_bytes(pong); } } + (Some(client), ClientMessage::Near { near }) => { + let start = self.inner.borrow_mut().set_near(client, near); + if start { + self.keep_awake().await; + } + } + (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(); inner.sockets.remove(&client); + inner.forget(client); if let Some(host) = inner.host.as_mut() { host.disconnect(ClientId(client), true); } @@ -842,6 +1081,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; @@ -859,6 +1103,7 @@ impl ZoneObject { role: Role::Player, name: request.name.clone(), extensions: request.extensions.clone(), + wake: false, }, source_zone: request.source_zone.clone(), issuer: issuer_zone.clone(), @@ -898,6 +1143,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 { @@ -909,21 +1155,186 @@ impl ZoneObject { } self.store_status().await; self.find_node(); + // 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 }); + // 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(()) } + /// 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) { + 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 }); + } + } + + /// 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"); 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; // Transfer tickets are single use across activations too. let mut principal = None; + let mut wake_grant = false; let joined = if hello.reconnect_token.is_empty() { let verified = { let inner = self.inner.borrow(); @@ -946,6 +1357,14 @@ impl ZoneObject { reject(ws, error::UNAUTHORIZED, "ticket is for another zone"); return Ok(()); } + // A passive watcher never wakes a sleeping zone; a near one + // whose ticket grants `wake` does. + let observer = claims.role == Role::Observer; + wake_grant = observer && claims.wake; + if cold && observer && !(hello.near && wake_grant) { + reject(ws, error::ASLEEP, "zone asleep"); + return Ok(()); + } let replayed = self .inner .borrow_mut() @@ -973,9 +1392,36 @@ 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()); + } + // 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 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) @@ -984,7 +1430,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, @@ -993,6 +1443,7 @@ impl ZoneObject { }; match joined { Ok(joined) => { + let mut observer = matches!(joined.viewer, Viewer::Observer(_)); { let mut inner = self.inner.borrow_mut(); let activation = inner.activation.clone(); @@ -1001,12 +1452,33 @@ impl ZoneObject { activation, })?; inner.sockets.insert(joined.client.0, ws.clone()); + if observer && wake_grant { + inner.may_wake.insert(joined.client.0); + } + if observer && hello.near { + // A near watcher (with the grant) keeps the zone + // awake like a player; without it, it is told so + // after its Welcome and stays passive. + inner.flush(); + inner.set_near(joined.client.0, true); + observer = !wake_grant; + } + 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 { @@ -1018,6 +1490,23 @@ impl ZoneObject { Ok(()) } + /// A near watcher arrived on an awake zone that is not ticking + /// (dormant): it is active again and ticks. + async fn keep_awake(&self) { + { + let mut inner = self.inner.borrow_mut(); + if inner.host.is_none() { + return; + } + if inner.state != RoomState::Active { + inner.state = RoomState::Active; + inner.report(ACTIVITY_ACTIVE); + } + } + self.store_status().await; + self.start_loop(); + } + fn after_join(&self, principal: String) { let Some(work) = Z::after_join(&self.env, &principal) else { return; @@ -1040,6 +1529,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 +1598,9 @@ impl ZoneObject { (stop, persist) }; self.spool().await; + self.refresh_dev_settings(); + self.refresh_content(); + self.refresh_if_due(); if persist { self.persist().await; if !stop { @@ -1162,6 +1655,15 @@ 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(); + inner.may_wake.clear(); + inner.near.clear(); // Dropped, so the next join restores from storage. inner.host = None; inner.state = RoomState::Cold; @@ -1289,6 +1791,8 @@ impl ZoneObject { "node": inner.node, "isolate": ISOLATE.with(|i| i.clone()), "players": host.map_or(0, |h| h.player_count()), + "observers": host.map_or(0, |h| h.observer_count()), + "near": inner.near.len(), "tick": host.map_or(0, |h| h.tick()), "hz": inner.options.as_ref().map_or(0, |o| o.tick_hz), "ticking": inner.ticking, @@ -1298,11 +1802,45 @@ 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), + "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) }, }) } } +/// 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 c9e4ab28e..b1599535f 100644 --- a/distributed_game/src/celld_zone/plan.rs +++ b/distributed_game/src/celld_zone/plan.rs @@ -36,6 +36,15 @@ 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"; +/// 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 /// object's id has no name on some celld paths). pub const ZONE_HEADER: &str = "x-celld-zone"; @@ -67,6 +76,11 @@ 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, + /// The world (`content-rollout` shard) whose live content version and + /// rollout the zone follows. + pub content_rollout: Option, } fn parse( @@ -116,6 +130,11 @@ 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")), + 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 9d798bae6..0901f8912 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 @@ -1039,6 +1064,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) @@ -1402,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/lib.rs b/distributed_game/src/lib.rs index 03fe2fdb8..51766bc20 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/metrics.rs b/distributed_game/src/metrics.rs index b4093ff71..7f834f743 100644 --- a/distributed_game/src/metrics.rs +++ b/distributed_game/src/metrics.rs @@ -682,6 +682,7 @@ mod tests { role: Role::Player, name: None, extensions: Default::default(), + wake: false, }; let joined = h.join(&claims, 0, "").unwrap(); metrics.record_input("z1", h.push_input(joined.client, 1, 0, &[1])); diff --git a/distributed_game/src/net/mod.rs b/distributed_game/src/net/mod.rs index cac7a3174..e7fab362c 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)] @@ -341,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, @@ -1230,6 +1160,7 @@ async fn run_zone( role: Role::Player, name: request.name.clone(), extensions: request.extensions.clone(), + wake: false, }, source_zone: request.source_zone.clone(), issuer: inner.host_id.clone(), @@ -1518,6 +1449,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))); @@ -1699,6 +1633,7 @@ enum Inbound { payload: Vec, }, Ping(f64), + Content(String), } async fn connection(inner: Arc>, zone_id: String, mut socket: WebSocket) @@ -1956,6 +1891,10 @@ 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), + // Native zones keep observers' zones running already; + // nearness only matters to celld zones. + Ok(ClientMessage::Near { .. }) => continue, Ok(ClientMessage::Leave) => { graceful = true; break; } Ok(ClientMessage::Hello(_)) => { let _ = socket.send(Message::Binary(error_frame(error::BAD_MESSAGE, "already joined").into())).await; @@ -2050,6 +1989,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, @@ -2069,39 +2012,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/net/ticket.rs b/distributed_game/src/net/ticket.rs index 33978b72b..0272d52a3 100644 --- a/distributed_game/src/net/ticket.rs +++ b/distributed_game/src/net/ticket.rs @@ -4,8 +4,10 @@ //! base64url(header) "." base64url(claims)))`, unpadded base64url. The header //! must be `{"alg":"HS256",...}`; claims are //! `{"sub","zone","exp","nonce","role"?}` where `exp` is Unix seconds and -//! `role` is `"player"` (default) or `"observer"`. Any HS256 JWT library can -//! mint them. The principal is taken **only** from a verified `sub`. +//! `role` is `"player"` (default) or `"observer"`; an optional `wake: true` +//! lets an observer wake the zone when near (celld zones, +//! [`TicketClaims::wake`]). Any HS256 JWT library can mint them. The +//! principal is taken **only** from a verified `sub`. //! //! **Transfer tickets** (zone handoff) are the same format with //! `"kind":"transfer"` plus `src` (source zone), `iss` (issuing host id), @@ -334,6 +336,7 @@ impl TicketKey { Some(ext) => TicketExtensions::from_json(ext.clone()) .map_err(|e| TicketError::Invalid(e.to_string()))?, }, + wake: claims.get("wake").and_then(Value::as_bool) == Some(true), }, claims, )) @@ -348,6 +351,9 @@ fn with_name(mut body: Value, claims: &TicketClaims) -> Value { if !claims.extensions.is_empty() { object.insert("ext".into(), claims.extensions.to_json()); } + if claims.wake { + object.insert("wake".into(), Value::Bool(true)); + } } body } @@ -399,6 +405,7 @@ mod tests { role: Role::Player, name: None, extensions: Default::default(), + wake: false, } } @@ -414,6 +421,20 @@ mod tests { ); } + #[test] + fn the_wake_grant_round_trips_and_defaults_to_off() { + let key = TicketKey::new("secret"); + let mut watcher = claims().with_wake(true); + watcher.role = Role::Observer; + let ticket = key.sign(&watcher); + assert!(key.verify(&ticket, 1_000).unwrap().wake); + let passive = key.sign(&TicketClaims { + role: Role::Observer, + ..claims() + }); + assert!(!key.verify(&passive, 1_000).unwrap().wake); + } + #[test] fn tampered_principal_is_rejected() { let key = TicketKey::new("secret"); diff --git a/distributed_game/src/netsim.rs b/distributed_game/src/netsim.rs new file mode 100644 index 000000000..44b7ad361 --- /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); + } +} diff --git a/distributed_game/src/protocol/messages.rs b/distributed_game/src/protocol/messages.rs index c0a010abe..d560788ea 100644 --- a/distributed_game/src/protocol/messages.rs +++ b/distributed_game/src/protocol/messages.rs @@ -18,6 +18,11 @@ 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; + /// Client → server Near (an observer asks to keep the zone awake, or + /// lets go). + pub const NEAR: u8 = 0x06; /// Server → client Welcome. pub const WELCOME: u8 = 0x80; /// Server → client Delta. @@ -80,6 +85,13 @@ 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; + /// An observer said it is near (Hello `near` / `Near`) but its ticket + /// does not grant `wake`: it stays a passive watcher. Sent after the + /// join, without closing; a fresh ticket may grant it. + pub const WAKE_DENIED: u32 = 11; } /// Client handshake. @@ -95,6 +107,12 @@ pub struct Hello { pub ticket: String, /// Reconnect token from a previous Welcome (empty for a fresh join). pub reconnect_token: String, + /// An observer that is *near* (e.g. its player is close to this zone): + /// on zones hosted as celld objects a near observer whose ticket grants + /// `wake` wakes a sleeping zone and keeps it awake like a player; + /// otherwise it is a passive watcher. Changed later with + /// [`ClientMessage::Near`]. Ignored for players. + pub near: bool, } /// Messages a client sends. @@ -118,6 +136,19 @@ 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, + }, + /// An observer is near the zone (keep it awake) or no longer is + /// (passive watcher again); see [`Hello::near`]. + Near { + /// Near (true) or passive (false). + near: bool, + }, } impl ClientMessage { @@ -131,7 +162,8 @@ impl ClientMessage { .string(&h.content_version) .varuint(h.capabilities) .string(&h.ticket) - .string(&h.reconnect_token); + .string(&h.reconnect_token) + .bool(h.near); } Self::Input { seq, @@ -149,6 +181,12 @@ impl ClientMessage { Self::Leave => { w.u8(tag::LEAVE); } + Self::Content { content_version } => { + w.u8(tag::CONTENT).string(content_version); + } + Self::Near { near } => { + w.u8(tag::NEAR).bool(*near); + } } w.into_bytes() } @@ -163,6 +201,7 @@ impl ClientMessage { capabilities: r.varuint()?, ticket: r.string()?, reconnect_token: r.string()?, + near: r.bool()?, }), tag::INPUT => Self::Input { seq: r.varuint()?, @@ -173,6 +212,10 @@ impl ClientMessage { client_time: r.f64()?, }, tag::LEAVE => Self::Leave, + tag::CONTENT => Self::Content { + content_version: r.string()?, + }, + tag::NEAR => Self::Near { near: r.bool()? }, other => return Err(DecodeError::UnknownMessage(other)), }; r.finish()?; diff --git a/distributed_game/src/replay.rs b/distributed_game/src/replay.rs index 4cbd1ecc0..7081e43da 100644 --- a/distributed_game/src/replay.rs +++ b/distributed_game/src/replay.rs @@ -634,6 +634,7 @@ fn get_claims(r: &mut Reader<'_>) -> Result { role, name, extensions, + wake: false, }) } diff --git a/distributed_game/src/schema.rs b/distributed_game/src/schema.rs index 1c41aeed3..86cc28d7b 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/src/world/content_rollout.rs b/distributed_game/src/world/content_rollout.rs new file mode 100644 index 000000000..bc6234733 --- /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 b07d536fa..7f1b6787b 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/src/zone.rs b/distributed_game/src/zone.rs index 984631dbd..56d45541e 100644 --- a/distributed_game/src/zone.rs +++ b/distributed_game/src/zone.rs @@ -79,6 +79,12 @@ pub struct TicketClaims { /// ticket's `ext`), checked by the `net` server against /// `ServerConfig::ticket_extensions`. Never identity or rewards. pub extensions: TicketExtensions, + /// The issuer lets this **observer** wake the zone and keep it awake + /// while it declares itself near ([`Hello::near`](crate::protocol::Hello::near), + /// zones hosted as celld objects); the ticket's `wake` claim. The + /// issuer decides who may (e.g. only rooms next to the principal's + /// own). Ignored for players. + pub wake: bool, } impl TicketClaims { @@ -98,9 +104,17 @@ impl TicketClaims { role, name: None, extensions: TicketExtensions::new(), + wake: false, } } + /// Grant an observer ticket the right to wake the zone when near + /// ([`wake`](Self::wake)). + pub fn with_wake(mut self, wake: bool) -> Self { + self.wake = wake; + self + } + /// Attach extension claims. pub fn with_extensions(mut self, extensions: TicketExtensions) -> Self { self.extensions = extensions; diff --git a/distributed_game/tests/authoring/main.rs b/distributed_game/tests/authoring/main.rs index c4e4c5163..ac5e38bdf 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/celld_zone/main.rs b/distributed_game/tests/celld_zone/main.rs index 37b9f5017..2d69b4fd2 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,15 @@ //! 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). +//! - `near_watchers`: `celld dev`. An observer whose ticket grants `wake` +//! and that says it is near wakes a sleeping zone and keeps it ticking; +//! `Near { near: false }` lets it go dormant, `Near { near: true }` makes +//! it active again, and when it lets go for good the zone hibernates. +//! Without the grant, or without saying near, a watcher stays passive. //! - `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 +51,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 +82,28 @@ 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)) +} + +/// An observer ticket that grants `wake` (a near watcher may wake the zone). +fn wake_ticket(principal: &str, zone: &str) -> String { + let exp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 120; + let nonce = format!( + "{principal}-{zone}-wake-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + TicketKey::new(TICKET_SECRET).sign( + &TicketClaims::new(principal, zone, exp, nonce, Role::Observer) + .with_name(principal) + .with_wake(true), + ) } type Ws = @@ -97,12 +141,17 @@ impl Player { } async fn hello(&mut self, ticket: &str) { + self.hello_near(ticket, false).await; + } + + async fn hello_near(&mut self, ticket: &str, near: bool) { self.send(ClientMessage::Hello(Hello { protocol_version: PROTOCOL_VERSION, content_version: String::new(), capabilities: 0, ticket: ticket.into(), reconnect_token: String::new(), + near, })) .await; } @@ -241,6 +290,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 +367,178 @@ 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 near_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); + let zone = "room-n"; + + // Near without the ticket's grant: passive, refused while asleep. + let mut eve = Player::connect(&base, zone).await; + eve.hello_near(&ticket_as("eve", zone, Role::Observer), true) + .await; + assert_eq!(eve.refused().await, error::ASLEEP); + // The grant without saying near: passive too. + let mut eve = Player::connect(&base, zone).await; + eve.hello_near(&wake_ticket("eve", zone), false).await; + assert_eq!(eve.refused().await, error::ASLEEP); + let info = get(&base, &format!("/rooms/{zone}/info")).await; + assert_eq!(info["awake"], false, "{info}"); + assert_eq!(info["wakes"], 0, "{info}"); + + // Granted and near: wakes the zone, which ticks with no player. + let mut near = Player::connect(&base, zone).await; + near.hello_near(&wake_ticket("ana", zone), true).await; + near.welcome().await; + until(&base, &format!("/zone-activity/{zone}"), long, |v| { + status(v) == "active" + }) + .await; + // Kept awake past the hibernation delay (2 s here). + tokio::time::sleep(Duration::from_secs(4)).await; + let info = get(&base, &format!("/rooms/{zone}/info")).await; + assert_eq!(info["ticking"], true, "{info}"); + assert_eq!(info["players"], 0, "{info}"); + assert_eq!(info["near"], 1, "{info}"); + assert_eq!( + status(&get(&base, &format!("/zone-activity/{zone}")).await), + "active" + ); + + // A watcher without the grant cannot become near. + let mut passive = Player::connect(&base, zone).await; + passive.hello(&ticket_as("bo", zone, Role::Observer)).await; + passive.welcome().await; + passive.send(ClientMessage::Near { near: true }).await; + let denied = passive + .expect(long, "WAKE_DENIED", |got| match got { + Got::Server(ServerMessage::Error { code, .. }) => Some(*code), + _ => None, + }) + .await; + assert_eq!(denied, error::WAKE_DENIED); + let info = get(&base, &format!("/rooms/{zone}/info")).await; + assert_eq!(info["near"], 1, "{info}"); + + // Passive again: dormant; near again: active (no reconnect). + near.send(ClientMessage::Near { near: false }).await; + until(&base, &format!("/zone-activity/{zone}"), long, |v| { + status(v) == "dormant" + }) + .await; + near.send(ClientMessage::Near { near: true }).await; + until(&base, &format!("/zone-activity/{zone}"), long, |v| { + status(v) == "active" + }) + .await; + let info = until(&base, &format!("/rooms/{zone}/info"), long, |v| { + v["ticking"] == true + }) + .await; + assert_eq!(info["wakes"], 1, "woken once: {info}"); + + // Let go for good: dormant, then hibernated; the watchers are closed. + near.send(ClientMessage::Near { near: false }).await; + until(&base, &format!("/zone-activity/{zone}"), long, |v| { + status(v) == "hibernated" + }) + .await; + for watcher in [&mut near, &mut passive] { + let closed = watcher + .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, &format!("/rooms/{zone}/info")).await; + assert_eq!(info["awake"], false, "{info}"); + + // A near watcher that disconnects releases the zone too. + let mut near = Player::connect(&base, zone).await; + near.hello_near(&wake_ticket("ana", zone), true).await; + near.welcome().await; + until(&base, &format!("/zone-activity/{zone}"), long, |v| { + status(v) == "active" + }) + .await; + drop(near); + until(&base, &format!("/zone-activity/{zone}"), long, |v| { + status(v) == "hibernated" + }) + .await; + let info = get(&base, &format!("/rooms/{zone}/info")).await; + assert_eq!(info["wakes"], 2, "{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/distributed_game/tests/content_rollout/main.rs b/distributed_game/tests/content_rollout/main.rs new file mode 100644 index 000000000..d854ee0e4 --- /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/authoring.json b/distributed_game/tests/fixtures/authoring.json index 492d1eb2d..ff79bed15 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/tests/fixtures/messages.json b/distributed_game/tests/fixtures/messages.json index ca36cf342..40f07d39d 100644 --- a/distributed_game/tests/fixtures/messages.json +++ b/distributed_game/tests/fixtures/messages.json @@ -4,26 +4,28 @@ "decoded": { "capabilities": 5, "content_version": "c1", + "near": false, "protocol_version": 1, "reconnect_token": "", "ticket": "t.k.n", "type": "Hello" }, "direction": "client_to_server", - "hex": "01010263310505742e6b2e6e00", + "hex": "01010263310505742e6b2e6e0000", "name": "hello" }, { "decoded": { "capabilities": 0, "content_version": "c1", + "near": false, "protocol_version": 1, "reconnect_token": "abcd", "ticket": "", "type": "Hello" }, "direction": "client_to_server", - "hex": "010102633100000461626364", + "hex": "01010263310000046162636400", "name": "hello_reconnect" }, { @@ -54,6 +56,47 @@ "hex": "04", "name": "leave" }, + { + "decoded": { + "content_version": "c2", + "type": "Content" + }, + "direction": "client_to_server", + "hex": "05026332", + "name": "content" + }, + { + "decoded": { + "capabilities": 0, + "content_version": "c1", + "near": true, + "protocol_version": 1, + "reconnect_token": "", + "ticket": "t.k.n", + "type": "Hello" + }, + "direction": "client_to_server", + "hex": "01010263310005742e6b2e6e0001", + "name": "hello_near" + }, + { + "decoded": { + "near": true, + "type": "Near" + }, + "direction": "client_to_server", + "hex": "0601", + "name": "near" + }, + { + "decoded": { + "near": false, + "type": "Near" + }, + "direction": "client_to_server", + "hex": "0600", + "name": "passive" + }, { "decoded": { "content_version": "c1", diff --git a/distributed_game/tests/golden/main.rs b/distributed_game/tests/golden/main.rs index 5ecb5acfc..b6eb18574 100644 --- a/distributed_game/tests/golden/main.rs +++ b/distributed_game/tests/golden/main.rs @@ -87,6 +87,7 @@ fn client_json(m: &ClientMessage) -> Value { "capabilities": h.capabilities, "ticket": h.ticket, "reconnect_token": h.reconnect_token, + "near": h.near, }), ClientMessage::Input { seq, @@ -99,6 +100,10 @@ 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 }) + } + ClientMessage::Near { near } => json!({ "type": "Near", "near": near }), } } @@ -320,6 +325,7 @@ fn messages() { capabilities: 5, ticket: "t.k.n".into(), reconnect_token: String::new(), + near: false, }), ), ( @@ -330,6 +336,7 @@ fn messages() { capabilities: 0, ticket: String::new(), reconnect_token: "abcd".into(), + near: false, }), ), ( @@ -347,6 +354,25 @@ fn messages() { }, ), ("leave", ClientMessage::Leave), + ( + "content", + ClientMessage::Content { + content_version: "c2".into(), + }, + ), + ( + "hello_near", + ClientMessage::Hello(Hello { + protocol_version: PROTOCOL_VERSION, + content_version: "c1".into(), + capabilities: 0, + ticket: "t.k.n".into(), + reconnect_token: String::new(), + near: true, + }), + ), + ("near", ClientMessage::Near { near: true }), + ("passive", ClientMessage::Near { near: false }), ]; for (name, msg) in client { let bytes = msg.encode(); diff --git a/distributed_game/tests/net_e2e/main.rs b/distributed_game/tests/net_e2e/main.rs index 68a376b48..57c763aeb 100644 --- a/distributed_game/tests/net_e2e/main.rs +++ b/distributed_game/tests/net_e2e/main.rs @@ -32,6 +32,7 @@ fn ticket(principal: &str, zone: &str, nonce: &str, role: Role) -> String { role, name: None, extensions: Default::default(), + wake: false, }) } @@ -79,6 +80,7 @@ async fn hello(ws: &mut Ws, ticket: String, reconnect_token: String) { capabilities: 0, ticket, reconnect_token, + near: false, }), ) .await; @@ -232,6 +234,7 @@ async fn client_joins_sends_inputs_receives_deltas_and_reconnects() { role: Role::Player, name: None, extensions: Default::default(), + wake: false, }); expect_error(&url, "z1", forged, error::UNAUTHORIZED).await; @@ -681,6 +684,7 @@ fn extended_ticket(nonce: &str, ext: serde_json::Value) -> String { role: Role::Player, name: None, extensions: TicketExtensions::from_json(ext).unwrap(), + wake: false, }) } @@ -774,6 +778,7 @@ async fn extension_claims_are_refused_without_a_declared_schema() { role: Role::Player, name: None, extensions: TicketExtensions::from_json(serde_json::json!({"match_id": "m"})).unwrap(), + wake: false, }); expect_error(&url, "z1", ticket, error::UNAUTHORIZED).await; server.shutdown(); diff --git a/distributed_game/tests/netcode/main.rs b/distributed_game/tests/netcode/main.rs index f5683d491..6943c37b0 100644 --- a/distributed_game/tests/netcode/main.rs +++ b/distributed_game/tests/netcode/main.rs @@ -25,6 +25,7 @@ fn claims(principal: &str, zone: &str) -> TicketClaims { role: Role::Player, name: None, extensions: Default::default(), + wake: false, } } diff --git a/distributed_game/tests/world/main.rs b/distributed_game/tests/world/main.rs index 165419928..264b6c78b 100644 --- a/distributed_game/tests/world/main.rs +++ b/distributed_game/tests/world/main.rs @@ -43,6 +43,7 @@ fn ticket(principal: &str, zone: &str, nonce: &str) -> String { role: Role::Player, name: None, extensions: Default::default(), + wake: false, }) } @@ -129,6 +130,7 @@ async fn hello(ws: &mut Ws, ticket: &str, token: &str, version: &str) { capabilities: 0, ticket: ticket.into(), reconnect_token: token.into(), + near: false, }), ) .await; @@ -335,6 +337,7 @@ async fn cross_host_handoff_follows_redirect_and_ticket_is_single_use() { role: Role::Player, name: None, extensions: Default::default(), + wake: false, }); assert!(join_url(&b.addr.zone_url("realm.west"), &forged, "") .await diff --git a/distributed_game/tests/world_host/main.rs b/distributed_game/tests/world_host/main.rs index d85e3b950..f3f48b8bc 100644 --- a/distributed_game/tests/world_host/main.rs +++ b/distributed_game/tests/world_host/main.rs @@ -21,6 +21,7 @@ fn claims(principal: &str, zone: &str) -> TicketClaims { role: Role::Player, name: None, extensions: Default::default(), + wake: false, } } diff --git a/distributed_game/tests/zone_runtime/main.rs b/distributed_game/tests/zone_runtime/main.rs index 2a9d45ae6..2850f7eaf 100644 --- a/distributed_game/tests/zone_runtime/main.rs +++ b/distributed_game/tests/zone_runtime/main.rs @@ -26,6 +26,7 @@ fn claims(principal: &str, zone: &str) -> TicketClaims { role: Role::Player, name: None, extensions: Default::default(), + wake: false, } } diff --git a/distributed_game_macros/src/lib.rs b/distributed_game_macros/src/lib.rs index 9612813d2..9779a4d12 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 3d21cd2ea..0f84bc412 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/distributed_game_macros/tests/compile_fail/replicated_unknown_correction.stderr b/distributed_game_macros/tests/compile_fail/replicated_unknown_correction.stderr index 4544bd728..8489371a7 100644 --- a/distributed_game_macros/tests/compile_fail/replicated_unknown_correction.stderr +++ b/distributed_game_macros/tests/compile_fail/replicated_unknown_correction.stderr @@ -1,4 +1,4 @@ -error: expected `ease`, `ease = MS`, `snap` or `replace` in #[correct(..)] +error: expected `ease`, `ease = MS`, `snap` or `replace` (with `ease`: `snap_above = M`) in #[correct(..)] --> tests/compile_fail/replicated_unknown_correction.rs:6:15 | 6 | #[correct(wobble)] diff --git a/examples/game/README.md b/examples/game/README.md index b2cbfcece..f7e834e53 100644 --- a/examples/game/README.md +++ b/examples/game/README.md @@ -1,42 +1,38 @@ -# 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 | |---|---|---|---| -| **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; **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) — except the doorway rooms **in reach** (toggle N, on by default: walk up to a doorway and the room behind it wakes and stays live); 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) | -| **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) | -`./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 (`arena::PlayerView`, `mobpit::zone::MobView`, …), -`#[derive(GameInput)]` for the input frame (`arena::movement::MoveInput`, -`gallery::ShotInput`, …), `#[derive(GameEvent)]` enums for events +for the kinds (`mobpit::zone::PlayerView`, `mobpit::zone::MobView`, …), +`#[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` (Arena, 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 +`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` -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, @@ -44,17 +40,13 @@ 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 -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 -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 -[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. +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, `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 (`rustup target add wasm32-unknown-unknown`), Node ≥ 22.18 (runs `.ts` @@ -63,177 +55,73 @@ 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. - -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. - -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. - -## 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 -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. +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. + +**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. + +**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 @@ -357,23 +245,22 @@ 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 ── arena.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) ══╝ │ - └──────────────────────────────────┘ └─────────────────────────────────────┘ + 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 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 @@ -383,22 +270,22 @@ 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 - (`arena-1`, `pit-1`, …). They are addressing, needed synchronously before +- **Zone id formats and defaults** — `pit..` (`parseRoom` / + `roomZone` in `mobpit/client.ts`) and each page's default zone + (`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. -- **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. +- **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. - **Presentation** — colors, tier display names, camera and HUD constants: client-only by nature. @@ -410,13 +297,16 @@ 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 - pouch + content log (The Freeze), zone lease + zone state - (multi-host demos), and the `game-demo-outbox` Queue consumer (relay) + celld cells — Crawler + presence + zone activity + dev settings + (`ZoneDevObject`: the rooms' NetSim) + hood / borough / city tiers + + 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 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 @@ -424,29 +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), ripple.rs inputs + steps, - grid.rs world + walker step + `#[game_pure]` grid_cell / grid_sample); compiled natively for the + 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/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), - gallery_smoke.rs, freeze_smoke.rs (binary content packs), grid_smoke.rs, - ripple_smoke.rs (+ support/ for two in-process hosts) + 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) @@ -461,23 +342,21 @@ 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 (grid), 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/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, - 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 + mobpit-headless, mobpit-network-headless (NetSim on celld rooms, + exact prediction, interest), mobpit-restarts-headless, leaderboard-headless, + mobpit-neighbours-headless (passive watchers), mobpit-wake-headless (proximity wake: doorway rooms in reach wake and release, corners never, toggle off), mobpit-ranged-headless (lag-compensated arrows), mobpit-events-headless, mobpit-content-headless (live content rollout), + mobpit-respawn-headless (a respawn is a cut), mobpit-doorway-bow-headless + (a bow fight, then doorways into a sleeping room: exact, no jump), + mobpit-bosses-headless (hood → borough → city) (+ helpers/demo-server.mjs, + helpers/mobpit.mjs, helpers/celld.mjs for a per-test `celld dev`) ``` @@ -503,9 +382,19 @@ 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, **Q** jump, **O** +debug overlay, **P** prediction, **G** server ghost (see the network panel +under [Run](#run)). + +**Jumping** is part of the predicted step: Mob Pit's state is the Arena +capsule plus a height (`PitState`: `z`, `vz`; take-off 8.5 m/s, gravity +24 m/s², ≈ 1.5 m up, ≈ 0.7 s in the air), predicted in WASM and +reconciled like the rest, replicated to everyone (`z`), and carried +through a doorway mid-air (the handoff carries `z`/`vz`). A mob's slam +is a ring on the floor: a player 0.9 m up or higher clears it +(`SLAM_CLEARANCE`; ranged hits will test the height too). Like every +step, gravity runs per command frame (one input per tick). 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 @@ -528,7 +417,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 @@ -536,6 +425,36 @@ the same Rust compiled to WebAssembly (`createMobpitPures().pitRoom`) for the floor colour and the minimap; nothing static is replicated. Seed: `MOBPIT_SEED` (worker var). +**World events.** Each borough runs its own events on its own schedule: +a room that wakes tells its borough (a zone fact, `borough.room_awake`), +whose cell arms its cycle (`ctx.schedule`): 10 s later an event starts +(in turn `invasion` — mobs aggro from twice as far and rise again a +second after falling —, `frenzy` — mobs 40 % faster —, `blessing` — +players heal five times as fast), runs 30 s, and the next starts 30 s +after it ends, for as long as rooms of the borough keep waking. Rooms +read the event with their tiers and apply it; the pages show a banner +with the time left. Events are effects only: XP stays the Crawler cell's +decision, so there is no "double XP" a room could claim. The borough's +domain events (`borough.event_started` / `event_ended` / `changed`) feed +the `boroughs` read model in `mobpit-service` (GraphQL, `@live`: the +SvelteKit page's "World events" list); the Vite page reads them from the +borough cells (`/dev/mobpit/tiers`). + +**Every room looks different.** Besides biome and mobs, `pit_room` lays +out the room's obstacles in one of six seeded patterns (`PATTERNS`: +classic pillars — always in the entrance —, a colonnade, a ring of columns +round a plinth, scattered boulders, a rounded hall with corner boulders, a +grove of trees). Generation keeps the door corridors, the walls, the spawn +points, the boss's home and every mob home clear and leaves at least a +player's width between obstacles, so every room stays one connected floor +(`rooms_vary_and_every_room_stays_one_floor` walks it on a 0.5 m lattice) +and the doorways sit where they always do. Collision is part of the step: +`pit_step` takes the room (`PitRoomContext`) as its predicted context and +generates the obstacles inside (cached), so the room and the browser's +WASM prediction collide identically (`prediction-wasm` replays streams in +rooms of every pattern); the page sets the context on every authority +change. Floors, walls and the light's tint follow the biome. + **Leaving and coming back.** A doorway is a passage: the predicted step (`pit_step`) lets you walk through the gap past the wall line, and 0.5 m past it (hysteresis, so dithering at the line does not bounce you back) the @@ -547,8 +466,40 @@ 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). **Wake rooms in reach** (HUD toggle, key N, on by +default and remembered per browser; the same on the SvelteKit page): rooms +connect only through the four mid-wall doorways, so only those four +neighbours can be woken — corner rooms never are. Within 11 m of a doorway +(`PIT_WORLD.wakeReach`, measured from your predicted position to the middle +of the doorway) the watcher of the room behind it says it is **near** +(`watchZones(...).setNear`, the protocol's `Near` message — no reconnect): +the room wakes and keeps ticking although nobody is in it, and is let go +only beyond 19 m (`wakeRelease`: 8 m of hysteresis), so you see it live +before you step through. Both thresholds stay below the 20 m from the +middle of the room to any doorway: at most **two** rooms are kept awake +(the two doorways of a corner) and none from the middle. The rule is shared +Rust (`game_sim::mobpit::rooms_in_reach`, a `#[game_pure]` the pages run as +WASM). The minimap draws a room kept awake with a solid cyan border (dashed: +watched only). Off: every neighbour is a passive watcher, as before. +*Abuse bound:* a watcher wakes a room only when its ticket carries the +`wake` grant, and the ticket routes (`/dev/ticket`, the SvelteKit +`/api/game/ticket`) grant it only for rooms within two doorways of the room +the principal's presence says it was last in (one doorway of slack: the next +room's watchers are asked for right after a doorway, before presence +follows); a watcher refused `WAKE_DENIED` gets a fresh ticket (at once, then +backing off). The grant is checked when the ticket is issued: a client that +keeps its near watchers open and walks far away keeps those rooms awake +until it closes them (bounded by how many rooms it walked past; tickets are +rate limited) — the rooms do not ask where their watchers' players are now. +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, @@ -559,6 +510,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 @@ -622,11 +606,11 @@ 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|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 -`cells/`. Only `arena` and `gallery` run 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 @@ -655,68 +639,50 @@ 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 -/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. - -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). +## 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 @@ -726,7 +692,7 @@ over the hosts, `cluster_server`, optional dev routes), uses 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 5bc8471cd..992dd76af 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 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"] @@ -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 fe4505f42..5cb7e4265 100644 --- a/examples/game/cells/src/lib.rs +++ b/examples/game/cells/src/lib.rs @@ -5,14 +5,24 @@ //! `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. //! -//! 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. +//! - `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). +//! +//! - `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 @@ -23,23 +33,12 @@ //! 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). -//! - `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 @@ -58,17 +57,22 @@ 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, 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::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::*; @@ -233,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 @@ -455,18 +245,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( @@ -654,73 +432,120 @@ 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 + }) +} + +/// A borough hears the saga (its hoods) and its rooms' own facts (a room +/// woke: the world event cycle); each command checks which it accepts. +fn borough_session(header: &dyn Fn(&str) -> Option) -> Option { + saga_session(header).or_else(|| fact_session(header)) +} + +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.as_ref().map(|b| b.state()), + "hoods_down": borough.map(|b| b.hoods_down()).unwrap_or_default(), + })) +} + +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, borough_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 { @@ -728,9 +553,128 @@ impl DurableObject for ZoneStateCell { } } -// ---------------------------------------------------------------- Rooms +/// 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)) + } + } +} -distributed_game::celld_zone_object!(PitRoom, mobpit_rooms::MobPit); +// ---------------------------------------------------------------- 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] +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 { @@ -777,11 +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-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", + Some(CONTENT_ROLLOUT_KIND) => "CONTENT_ROLLOUT", _ => return json_status(json!({ "error": "unknown cell kind" }), 404), }; let Some(shard) = parts.get(1) else { @@ -809,6 +754,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 e18bdd9b5..120b6201c 100644 --- a/examples/game/cells/wrangler.jsonc +++ b/examples/game/cells/wrangler.jsonc @@ -11,23 +11,26 @@ "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_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": "CONTENT_ROLLOUT", "class_name": "ContentRolloutObject" } ] }, + // 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"] } - ], - // Hibernation snapshots above the inline limit (celld's object storage). - "r2_buckets": [ - { "binding": "ZONE_SNAPSHOTS", "bucket_name": "zone-snapshots" } + { "tag": "v4", "new_sqlite_classes": ["PitRoom", "ZoneActivityObject"] }, + { "tag": "v5", "new_sqlite_classes": ["ZoneDevObject"] }, + { "tag": "v6", "new_sqlite_classes": ["HoodCell", "BoroughCell", "CityCell"] }, + { "tag": "v7", "new_sqlite_classes": ["ContentRolloutObject"] } ], "queues": { "producers": [ @@ -52,6 +55,11 @@ "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", + // Live content updates: rooms follow `content-rollout/world`. + "ZONE_CONTENT_ROLLOUT": "world" } } diff --git a/examples/game/client/arena/index.html b/examples/game/client/arena/index.html deleted file mode 100644 index 1b3276c8a..000000000 --- a/examples/game/client/arena/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Arena Demo - - - -
- - - diff --git a/examples/game/client/freeze/index.html b/examples/game/client/freeze/index.html deleted file mode 100644 index 9d602545e..000000000 --- a/examples/game/client/freeze/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - The Freeze Demo - - - -
- - - diff --git a/examples/game/client/gallery/index.html b/examples/game/client/gallery/index.html deleted file mode 100644 index fd203adb5..000000000 --- a/examples/game/client/gallery/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Shooting Gallery Demo - - - -
- - - diff --git a/examples/game/client/grid/index.html b/examples/game/client/grid/index.html deleted file mode 100644 index baf66d5b0..000000000 --- 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 4615fc991..71357ad1c 100644 --- a/examples/game/client/index.html +++ b/examples/game/client/index.html @@ -9,15 +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).

    -
  • 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.
  • +
  • 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).
  • -
  • 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/ripple/index.html b/examples/game/client/ripple/index.html deleted file mode 100644 index f98f2f69d..000000000 --- a/examples/game/client/ripple/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Boss Ripple Demo - - - -
- - - diff --git a/examples/game/client/src/arena/client.ts b/examples/game/client/src/arena/client.ts deleted file mode 100644 index 157e29efd..000000000 --- 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 1fceb6d29..000000000 --- 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/client.ts b/examples/game/client/src/freeze/client.ts deleted file mode 100644 index 86b290238..000000000 --- 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 777719a1b..000000000 --- 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 e8a4ae94f..000000000 --- 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/arena/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/gallery/client.ts b/examples/game/client/src/gallery/client.ts deleted file mode 100644 index 240229eb7..000000000 --- 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 cafaa140a..000000000 --- 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 ac33338ab..000000000 --- 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/arena/client.ts b/examples/game/client/src/generated/arena/client.ts deleted file mode 100644 index 35174cb12..000000000 --- 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 935e5326b..000000000 --- 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 76b8dadb1..000000000 --- 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 e138f392e..000000000 --- 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 5aa128ccb..000000000 --- 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 9d66870d1..000000000 --- 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 60cb7d0ab..000000000 --- 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 14d787ce9..000000000 --- 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/client.ts b/examples/game/client/src/generated/freeze/client.ts deleted file mode 100644 index 79e78f6d1..000000000 --- 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 49ef025bb..000000000 --- a/examples/game/client/src/generated/freeze/constants.ts +++ /dev/null @@ -1,75 +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 { FreezeWorld } from './events.ts'; - -/** `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 0e4cebdbd..000000000 --- a/examples/game/client/src/generated/freeze/events.ts +++ /dev/null @@ -1,230 +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 `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 728a97c9c..000000000 --- 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 f4050e863..000000000 --- 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 c4d44deed..000000000 --- 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 5630875eb..000000000 --- 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 6715a24bf..000000000 --- a/examples/game/client/src/generated/freeze/surface.json +++ /dev/null @@ -1,865 +0,0 @@ -{ - "codecs": [ - "Pack" - ], - "constants": [ - { - "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": "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": "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/src/generated/gallery/client.ts b/examples/game/client/src/generated/gallery/client.ts deleted file mode 100644 index d14738b41..000000000 --- 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 8e41724fa..000000000 --- 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 2ccc19923..000000000 --- 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 8f76cd21c..000000000 --- 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 c6e3dccc1..000000000 --- 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 1bf71cebe..000000000 --- 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 9b94c5850..000000000 --- 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/src/generated/grid/client.ts b/examples/game/client/src/generated/grid/client.ts deleted file mode 100644 index 325a29164..000000000 --- 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 8d16e48fd..000000000 --- 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 3df9d2356..000000000 --- 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 3b6637722..000000000 --- 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 6fbbdb41d..000000000 --- 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 22b33bb51..000000000 --- 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 79f06eb38..000000000 --- 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 3adcaa89e..000000000 --- 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 154cd36ac..000000000 --- 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/generated/mobpit/constants.ts b/examples/game/client/src/generated/mobpit/constants.ts index 2b523df3e..944647547 100644 --- a/examples/game/client/src/generated/mobpit/constants.ts +++ b/examples/game/client/src/generated/mobpit/constants.ts @@ -1,29 +1,81 @@ // @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']; /** `pit_rules`, as the client decodes it. */ 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, 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. */ 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, hysteresis: 0.5, maxRoom: 1000000, - biomes: ['highlands', 'steppe', 'forest', 'meadow'] + biomes: ['highlands', 'steppe', 'forest', 'meadow'], + wakeReach: 11, + wakeRelease: 19 }; + +/** `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 b98152277..1c7321279 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; @@ -10,14 +20,19 @@ export interface MobTier { readonly tele: number; } -/** Record `MoveState` (`#[derive(GameRecord)]`). */ -export interface MoveState { +/** Record `Obstacle` (`#[derive(GameRecord)]`). */ +export interface Obstacle { readonly x: number; readonly y: number; - readonly vx: number; - readonly vy: number; - readonly dash: number; - readonly cd: number; + readonly r: number; + readonly look: number; +} + +/** Record `Pillar` (`#[derive(GameRecord)]`). */ +export interface Pillar { + readonly x: number; + readonly y: number; + readonly r: number; } /** Record `PitRoomContext` (`#[derive(GameRecord)]`). */ @@ -39,6 +54,12 @@ 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; + readonly pattern: number; + readonly obstacles: readonly Obstacle[]; } /** Record `PitRules` (`#[derive(GameRecord)]`). */ @@ -48,18 +69,41 @@ 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)]`). */ +export interface PitState { + readonly x: number; + readonly y: number; + readonly vx: number; + readonly vy: number; + readonly dash: number; + readonly cd: number; + readonly z: number; + readonly vz: number; } /** Record `PitWorld` (`#[derive(GameRecord)]`). */ 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; readonly hysteresis: number; readonly maxRoom: number; readonly biomes: readonly string[]; + readonly wakeReach: number; + readonly wakeRelease: number; } /** Record `Profile` (`#[derive(GameRecord)]`). */ @@ -84,6 +128,56 @@ const PROFILE_TYPE: WireType = { ] }; +/** Record `Reach` (`#[derive(GameRecord)]`). */ +export interface Reach { + readonly near: number; +} + +/** Record `ReachContext` (`#[derive(GameRecord)]`). */ +export interface ReachContext { + readonly x: number; + readonly y: number; + readonly near: number; +} + +/** 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 event: string; + readonly eventUntilMs: number; + 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' } }], + ['event', { type: 'short_str' }], + ['eventUntilMs', { type: 'u64' }], + ['cityBossesDown', { type: 'u32' }], + ['banner', { type: 'short_str' }], + ] +}; + /** Event `hello` (id 1). */ export interface HelloEvent { readonly entity: number; @@ -95,6 +189,7 @@ export interface HelloEvent { readonly seed: number; readonly restored: boolean; readonly roomKills: number; + readonly viewRadius: number; } const HELLO_LAYOUT: PayloadLayout = [ @@ -107,6 +202,7 @@ const HELLO_LAYOUT: PayloadLayout = [ ['seed', { type: 'u32' }], ['restored', { type: 'bool' }], ['roomKills', { type: 'u32' }], + ['viewRadius', { type: 'f32' }], ]; /** Event `profile` (id 2). */ @@ -175,6 +271,58 @@ 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' }], +]; + +/** 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' }], +]; + +/** 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; @@ -190,6 +338,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, @@ -197,6 +355,10 @@ export const EVENT = { kill: 3, saved: 4, died: 6, + world: 7, + bossDown: 8, + shot: 9, + stale: 10, } as const; /** Event payload types by name. */ @@ -206,6 +368,10 @@ export interface Events { kill: KillEvent; saved: SavedEvent; died: DiedEvent; + world: WorldEvent; + bossDown: BossDownEvent; + shot: ShotEvent; + stale: StaleEvent; } /** Codecs of every event. */ @@ -240,6 +406,30 @@ 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 + }, + shot: { + id: 9, + name: 'shot', + 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`. */ @@ -262,11 +452,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. */ @@ -277,6 +471,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/input.ts b/examples/game/client/src/generated/mobpit/input.ts index 7914746a8..1940d55a1 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; @@ -17,6 +21,10 @@ export const BITS = { buttons: { dash: 1, attack: 2, + jump: 4, + fire: 8, + noComp: 16, + aim: 32, }, } as const; @@ -28,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: 3 }, + { buttons: 63 }, + undefined, + undefined, + undefined, + undefined, ]; /** Payload bytes of one input frame. */ diff --git a/examples/game/client/src/generated/mobpit/predict.ts b/examples/game/client/src/generated/mobpit/predict.ts index fcfeb0e96..844c5306e 100644 --- a/examples/game/client/src/generated/mobpit/predict.ts +++ b/examples/game/client/src/generated/mobpit/predict.ts @@ -1,8 +1,9 @@ // @generated by `distributed game-client` from game surface `mobpit` (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 { createWasmPrediction, type PredictStateLayout, type WasmPrediction, type WasmSource, type PayloadLayout } from '@hops-ops/distributed/game'; +import type { PitRoomContext } from './events.ts'; import { encodeInput, type PitInput } from './input.ts'; /** The predicted step: `#[predict]` fn `pit_step` of Cargo package `game_sim`, predicting kind `player`. */ @@ -10,11 +11,11 @@ export const PREDICT = { kind: 'player', export: 'pit_step', package: 'game_sim', - state: 'MoveState', + state: 'PitState', abi: 1, } as const; -/** Predicted `player` fields (record `MoveState`): wire field name and codec type, in record order. */ +/** Predicted `player` fields (record `PitState`): wire field name and codec type, in record order. */ export const PREDICT_STATE: PredictStateLayout = [ ['x', { type: 'f32' }], ['y', { type: 'f32' }], @@ -22,13 +23,19 @@ export const PREDICT_STATE: PredictStateLayout = [ ['vy', { type: 'f32' }], ['dash', { type: 'u8' }], ['cd', { type: 'u8' }], + ['z', { type: 'f32' }], + ['vz', { type: 'f32' }], ]; -/** The step takes no context. */ -export type PredictContext = undefined; +/** What the step reads besides state and input (`prediction.setContext`). */ +export type PredictContext = PitRoomContext; -/** The step takes no context. */ -export const PREDICT_CONTEXT = undefined; +/** Layout of the context record `PitRoomContext`. */ +export const PREDICT_CONTEXT: PayloadLayout | undefined = [ + ['seed', { type: 'u32' }], + ['x', { type: 'i32' }], + ['y', { type: 'i32' }], +]; /** The compiled step (`distributed game-wasm`); bundlers emit it as an asset. */ export const PREDICT_WASM_URL: URL = new URL('./wasm/mobpit.wasm', import.meta.url); @@ -38,7 +45,7 @@ export type MobpitPrediction = WasmPrediction; /** * Load the compiled step (default `PREDICT_WASM_URL`) as prediction: `predict` - * leaves the state unchanged until `ready` resolves. + * leaves the state unchanged until `ready` resolves and a context is set. */ export function createMobpitPrediction(source: WasmSource = PREDICT_WASM_URL): MobpitPrediction { return createWasmPrediction({ diff --git a/examples/game/client/src/generated/mobpit/pures.ts b/examples/game/client/src/generated/mobpit/pures.ts index e2d25d5c2..3bcbad6f1 100644 --- a/examples/game/client/src/generated/mobpit/pures.ts +++ b/examples/game/client/src/generated/mobpit/pures.ts @@ -3,7 +3,17 @@ // Do not edit: change the Rust definitions and regenerate. import { createWasmPures, type WasmPures, type WasmSource, type WireType } from '@hops-ops/distributed/game'; -import type { PitRoomContext, PitRoomLayout } from './events.ts'; +import type { PitRoomContext, PitRoomLayout, Reach, ReachContext } from './events.ts'; + +const OBSTACLE_TYPE: WireType = { + type: 'record', + fields: [ + ['x', { type: 'f32' }], + ['y', { type: 'f32' }], + ['r', { type: 'f32' }], + ['look', { type: 'u8' }], + ] +}; const PIT_ROOM_CONTEXT_TYPE: WireType = { type: 'record', @@ -29,12 +39,35 @@ 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' }], + ['pattern', { type: 'u8' }], + ['obstacles', { type: 'list', item: OBSTACLE_TYPE }], + ] +}; + +const REACH_TYPE: WireType = { + type: 'record', + fields: [ + ['near', { type: 'u8' }], + ] +}; + +const REACH_CONTEXT_TYPE: WireType = { + type: 'record', + fields: [ + ['x', { type: 'f32' }], + ['y', { type: 'f32' }], + ['near', { type: 'u8' }], ] }; /** The pure functions (`#[game_pure]`) of Cargo package `game_sim`: wrapper name → module export. */ export const PURES = { pitRoom: { export: 'pit_room', abi: 1 }, + roomsInReach: { export: 'rooms_in_reach', abi: 1 }, } as const; /** The compiled module (`distributed game-wasm`; shared with the predicted step); bundlers emit it as an asset. */ @@ -44,6 +77,8 @@ export const PURES_WASM_URL: URL = new URL('./wasm/mobpit.wasm', import.meta.url export interface MobpitPureFns { /** `pit_room` (`dg_pure_pit_room`). */ pitRoom(arg: PitRoomContext): PitRoomLayout; + /** `rooms_in_reach` (`dg_pure_rooms_in_reach`). */ + roomsInReach(arg: ReachContext): Reach; } /** The `mobpit` pure functions plus `ready` / `loaded()` / `module()`. */ @@ -56,5 +91,6 @@ export type MobpitPures = WasmPures; export function createMobpitPures(source: WasmSource = PURES_WASM_URL): MobpitPures { return createWasmPures(source, { pitRoom: { exportName: 'pit_room', arg: PIT_ROOM_CONTEXT_TYPE, output: PIT_ROOM_LAYOUT_TYPE }, + roomsInReach: { exportName: 'rooms_in_reach', arg: REACH_CONTEXT_TYPE, output: REACH_TYPE }, }); } diff --git a/examples/game/client/src/generated/mobpit/schema.ts b/examples/game/client/src/generated/mobpit/schema.ts index cf619af1f..497a6ca7e 100644 --- a/examples/game/client/src/generated/mobpit/schema.ts +++ b/examples/game/client/src/generated/mobpit/schema.ts @@ -27,6 +27,10 @@ export interface PlayerFields { readonly dash: number; /** `u8` */ readonly cd: number; + /** `quant [-4, 3.9995117] / 14 bits` */ + readonly z: number; + /** `quant [-16, 15.9921875] / 12 bits` */ + readonly vz: number; /** `short_str`, priority 0.2 */ readonly name: string; /** `u8` */ @@ -39,6 +43,8 @@ export interface PlayerFields { readonly level: number; /** `bool` */ readonly dead: boolean; + /** `u8` */ + readonly shot: number; } /** True for a `player` entity. */ @@ -56,12 +62,15 @@ export function playerFields(entity: { readonly fields: EntityFields }): PlayerF vy: fieldNumber(f, 'vy'), dash: fieldNumber(f, 'dash'), cd: fieldNumber(f, 'cd'), + z: fieldNumber(f, 'z'), + vz: fieldNumber(f, 'vz'), name: fieldString(f, 'name'), hp: fieldNumber(f, 'hp'), face: fieldNumber(f, 'face'), swing: fieldNumber(f, 'swing'), level: fieldNumber(f, 'level'), dead: fieldBool(f, 'dead'), + shot: fieldNumber(f, 'shot'), }; } @@ -170,12 +179,15 @@ export const FIELD_TYPES = { vy: { type: 'quant', min: -16, max: 15.9921875, bits: 12 }, dash: { type: 'u8' }, cd: { type: 'u8' }, + z: { type: 'quant', min: -4, max: 3.99951171875, bits: 14 }, + vz: { type: 'quant', min: -16, max: 15.9921875, bits: 12 }, name: { type: 'short_str' }, hp: { type: 'u8' }, face: { type: 'angle', bits: 8 }, swing: { type: 'u8' }, level: { type: 'u16' }, dead: { type: 'bool' }, + shot: { type: 'u8' }, }, mob: { x: { type: 'quant', min: -20, max: 20, bits: 14 }, @@ -204,17 +216,20 @@ export const FIELD_TYPES = { /** 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 }, + x: { mode: 'ease', durationMs: 150, snapAbove: 2 }, + y: { mode: 'ease', durationMs: 150, snapAbove: 2 }, vx: { mode: 'snap' }, vy: { mode: 'snap' }, dash: { mode: 'snap' }, cd: { mode: 'snap' }, + z: { mode: 'ease', durationMs: 100 }, + vz: { mode: 'snap' }, name: { mode: 'replace' }, hp: { mode: 'replace' }, face: { mode: 'replace' }, 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 4299b39e5..b95ed46f2 100644 --- a/examples/game/client/src/generated/mobpit/surface.json +++ b/examples/game/client/src/generated/mobpit/surface.json @@ -1,5 +1,65 @@ { "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": { + "item": { + "type": "short_str" + }, + "type": "list" + }, + "value": [ + "pillars", + "colonnade", + "ring", + "rubble", + "hall", + "grove" + ] + }, { "name": "pit_rules", "type": { @@ -9,6 +69,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", @@ -33,6 +105,11 @@ "radius": 0.800000011920929, "tele": 2.5999999046325684, "tier": 3 + }, + { + "radius": 1.2000000476837158, + "tele": 3.200000047683716, + "tier": 4 } ] } @@ -50,14 +127,80 @@ "forest", "meadow" ], + "dash_cooldown": 45, "default_seed": 186474221, "door_half": 2.5, "half": 20.0, "hysteresis": 0.5, "max_room": 1000000, "passage": 2.0, - "room_size": 40.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, + "wake_reach": 11.0, + "wake_release": 19.0 } + }, + { + "name": "weather", + "type": { + "item": { + "type": "short_str" + }, + "type": "list" + }, + "value": [ + "storm", + "rain", + "clearing", + "clear" + ] } ], "events": [ @@ -119,6 +262,12 @@ "type": { "type": "u32" } + }, + { + "name": "view_radius", + "type": { + "type": "f32" + } } ], "kind": "fields" @@ -284,6 +433,125 @@ ], "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" + } + }, + { + "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" + } + }, + { + "id": 10, + "name": "stale", + "payload": { + "fields": [ + { + "name": "gate", + "type": { + "type": "short_str" + } + }, + { + "name": "version", + "type": { + "type": "short_str" + } + } + ], + "kind": "fields" + } } ], "input": { @@ -311,12 +579,46 @@ { "buttons": [ "dash", - "attack" + "attack", + "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" @@ -327,7 +629,8 @@ { "correction": { "durationMs": 150, - "mode": "ease" + "mode": "ease", + "snapAbove": 2.0 }, "name": "x", "priority": 1.0, @@ -341,7 +644,8 @@ { "correction": { "durationMs": 150, - "mode": "ease" + "mode": "ease", + "snapAbove": 2.0 }, "name": "y", "priority": 1.0, @@ -398,6 +702,33 @@ "type": "u8" } }, + { + "correction": { + "durationMs": 100, + "mode": "ease" + }, + "name": "z", + "priority": 1.0, + "type": { + "bits": 14, + "max": 3.9995117, + "min": -4.0, + "type": "quant" + } + }, + { + "correction": { + "mode": "snap" + }, + "name": "vz", + "priority": 1.0, + "type": { + "bits": 12, + "max": 15.9921875, + "min": -16.0, + "type": "quant" + } + }, { "correction": { "mode": "replace" @@ -458,6 +789,16 @@ "type": { "type": "bool" } + }, + { + "correction": { + "mode": "replace" + }, + "name": "shot", + "priority": 1.0, + "type": { + "type": "u8" + } } ], "id": 1, @@ -654,15 +995,38 @@ ], "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", "predict": { "abi": 1, + "context": "PitRoomContext", "export": "pit_step", "kind": "player", "package": "game_sim", - "state": "MoveState" + "state": "PitState" }, "protocolVersion": 1, "pures": [ @@ -678,72 +1042,137 @@ "type": "record" }, "package": "game_sim" + }, + { + "abi": 1, + "arg": { + "name": "ReachContext", + "type": "record" + }, + "export": "rooms_in_reach", + "output": { + "name": "Reach", + "type": "record" + }, + "package": "game_sim" } ], "records": [ { "fields": [ { - "name": "tier", + "name": "version", "type": { - "type": "u8" + "type": "short_str" } }, { - "name": "radius", + "name": "name", "type": { - "type": "f32" + "type": "short_str" } }, { - "name": "tele", + "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": "MobTier" + "name": "ContentPack" }, { "fields": [ { - "name": "x", + "name": "tier", "type": { - "type": "f32" + "type": "u8" } }, { - "name": "y", + "name": "radius", "type": { "type": "f32" } }, { - "name": "vx", + "name": "tele", + "type": { + "type": "f32" + } + } + ], + "name": "MobTier" + }, + { + "fields": [ + { + "name": "x", "type": { "type": "f32" } }, { - "name": "vy", + "name": "y", "type": { "type": "f32" } }, { - "name": "dash", + "name": "r", "type": { - "type": "u8" + "type": "f32" } }, { - "name": "cd", + "name": "look", "type": { "type": "u8" } } ], - "name": "MoveState" + "name": "Obstacle" + }, + { + "fields": [ + { + "name": "x", + "type": { + "type": "f32" + } + }, + { + "name": "y", + "type": { + "type": "f32" + } + }, + { + "name": "r", + "type": { + "type": "f32" + } + } + ], + "name": "Pillar" }, { "fields": [ @@ -791,6 +1220,46 @@ }, "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": "pattern", + "type": { + "type": "u8" + } + }, + { + "name": "obstacles", + "type": { + "item": { + "name": "Obstacle", + "type": "record" + }, + "type": "list" + } } ], "name": "PitRoomLayout" @@ -833,10 +1302,102 @@ "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" }, + { + "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": "z", + "type": { + "type": "f32" + } + }, + { + "name": "vz", + "type": { + "type": "f32" + } + } + ], + "name": "PitState" + }, { "fields": [ { @@ -851,6 +1412,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": { @@ -889,6 +1472,18 @@ }, "type": "list" } + }, + { + "name": "wake_reach", + "type": { + "type": "f32" + } + }, + { + "name": "wake_release", + "type": { + "type": "f32" + } } ], "name": "PitWorld" @@ -934,6 +1529,40 @@ ], "name": "Profile" }, + { + "fields": [ + { + "name": "near", + "type": { + "type": "u8" + } + } + ], + "name": "Reach" + }, + { + "fields": [ + { + "name": "x", + "type": { + "type": "f32" + } + }, + { + "name": "y", + "type": { + "type": "f32" + } + }, + { + "name": "near", + "type": { + "type": "u8" + } + } + ], + "name": "ReachContext" + }, { "fields": [ { @@ -956,6 +1585,98 @@ } ], "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": "event", + "type": { + "type": "short_str" + } + }, + { + "name": "event_until_ms", + "type": { + "type": "u64" + } + }, + { + "name": "city_bosses_down", + "type": { + "type": "u32" + } + }, + { + "name": "banner", + "type": { + "type": "short_str" + } + } + ], + "name": "WorldTiers" } ], "schemaVersion": 3 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 089b35949..000000000 --- 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 9e1ab7c6d..000000000 --- a/examples/game/client/src/generated/ripple/constants.ts +++ /dev/null @@ -1,21 +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 { RippleWorld } from './events.ts'; - -/** `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 2915a4b94..000000000 --- a/examples/game/client/src/generated/ripple/events.ts +++ /dev/null @@ -1,180 +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 `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 734c924a7..000000000 --- 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 7280c9a40..000000000 --- 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 4c5324a76..000000000 --- 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 19daac914..000000000 --- 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 3b1bae7b2..000000000 --- a/examples/game/client/src/generated/ripple/surface.json +++ /dev/null @@ -1,569 +0,0 @@ -{ - "constants": [ - { - "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": "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": "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/grid/client.ts b/examples/game/client/src/grid/client.ts deleted file mode 100644 index 923799add..000000000 --- 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 0c190f7ca..000000000 --- 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 9ee01916b..000000000 --- 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 bf8be5d22..347824c15 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; @@ -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 91155023f..74b74388d 100644 --- a/examples/game/client/src/mobpit/client.ts +++ b/examples/game/client/src/mobpit/client.ts @@ -16,11 +16,45 @@ * 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). + * + * Proximity wake ("Wake rooms in reach", `wake`, default on): rooms connect + * only through the four mid-wall doorways, so only those four neighbours + * can be woken. Within `PIT_WORLD.wakeReach` (11 m) of a doorway — from the + * predicted position — the watcher of the room behind it says it is *near* + * (`watchZones(...).setNear`): the room wakes and stays live until the + * player is beyond `wakeRelease` (19 m) of that doorway (`rooms_in_reach`, + * shared Rust as WASM: at most the two doorways of a corner, nothing from + * the middle of the room, corner rooms never). The services grant `wake` + * only on observer tickets for rooms within two doorways of the player's + * last room. Off: every neighbour is a passive watcher. + * + * 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 type { EntityFields, GameSocketFactory, RenderedEntity } 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'; @@ -67,6 +101,29 @@ export function roomZone(x: number, y: number): string { return `pit.${x}.${y}`; } +/** The doorway neighbours of `room` behind `near` bits (`rooms_in_reach`: east, west, north, south). */ +export function reachRooms(room: readonly [number, number], near: number): string[] { + const doors: [number, [number, number]][] = [ + [1, [1, 0]], + [2, [-1, 0]], + [4, [0, 1]], + [8, [0, -1]] + ]; + return doors.filter(([bit]) => (near & bit) !== 0).map(([, [dx, dy]]) => roomZone(room[0] + dx, room[1] + dy)); +} + +/** 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 @@ -82,7 +139,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 @@ -92,6 +150,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). */ @@ -134,6 +245,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; @@ -145,15 +263,54 @@ export type RoomInfo = Readonly<{ node: string; isolate: string; players: number; + /** Observers connected, and how many of them keep the room awake (near). */ + observers?: number; + near?: number; + /** The room is ticking (active). */ + ticking?: boolean; tick: number; 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 & 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; @@ -167,6 +324,29 @@ 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; + /** The doorway neighbours kept awake now (proximity wake; empty when off). */ + near(): ReadonlySet; + /** Proximity wake ("Wake rooms in reach") on? */ + wakeRooms(): boolean; + /** Turn proximity wake on or off (off: every neighbour passive). */ + setWakeRooms(on: boolean): void; + /** Live content (version, pack, freeze, staleness). */ + content(): ContentView; + /** Stop watching neighbours and close the client. */ + close(): void; }>; export type MobPitClientOptions = Readonly<{ @@ -174,10 +354,31 @@ 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; + /** Proximity wake: keep the doorway neighbours in reach awake (default true). */ + wake?: 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, @@ -187,27 +388,174 @@ 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) ); + // The predicted step's context is the room (its seeded obstacles are + // generated inside the step): follow the authority and the world seed. + const setRoomContext = () => { + const room = parseRoom(typed.client.authority?.zoneId ?? ''); + if (room === undefined) return; + typed.prediction?.setContext({ seed: hello.get()?.seed ?? pit.PIT_WORLD.defaultSeed, x: room[0], y: room[1] }); + }; + typed.client.on('authority', setRoomContext); + typed.on('hello', setRoomContext); 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) => { - 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(); + + // ---- 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 = () => { 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); + // ---- proximity wake ---- + let wake = options.wake ?? true; + let reachRoom: string | undefined; + let reachBits = 0; + let near: ReadonlySet = new Set(); + const updateReach = () => { + if (watcher === undefined) return; + const room = typed.client.authority?.zoneId; + const at = parseRoom(room ?? ''); + // The hysteresis is per room (a doorway re-frames everything). + if (room !== reachRoom) { + reachRoom = room; + reachBits = 0; + } + const meNow = me(); + let bits = 0; + if (wake && at !== undefined && meNow !== undefined && meNow.zoneId === room && pures.loaded()) { + const p = pit.playerFields(meNow); + bits = pures.roomsInReach({ x: p.x, y: p.y, near: reachBits }).near; + } + reachBits = bits; + const rooms = at === undefined ? [] : reachRooms(at, bits); + if (rooms.length === near.size && rooms.every((z) => near.has(z))) return; + near = new Set(rooms); + watcher.setNear(rooms); + }; + const reachTimer = setInterval(updateReach, 100); + // Never keep a Node process alive for this. + (reachTimer as { unref?: () => void }).unref?.(); return { ...typed, hello: hello.get, + world: () => world, profile: profile.get, room: () => typed.client.authority?.zoneId, me, @@ -217,7 +565,28 @@ 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(), + near: () => near, + wakeRooms: () => wake, + setWakeRooms(on) { + wake = on; + updateReach(); + }, + content, + 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: () => { + clearInterval(reachTimer); + watcher?.stop(); + typed.client.close(); + } }; } @@ -277,6 +646,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'); @@ -311,6 +685,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 b8e8c8ecc..180ee3e08 100644 --- a/examples/game/client/src/mobpit/main.ts +++ b/examples/game/client/src/mobpit/main.ts @@ -15,34 +15,53 @@ * (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. + * + * Next door: the 8 rooms around yours are watched (their mobs drawn where + * they are) without keeping them awake. "Wake rooms in reach" (N, on by + * default, remembered): walk toward a doorway and the room behind it wakes + * and stays live until you walk away (minimap: solid cyan border = kept + * awake, dashed = watched only). */ 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'; 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, + CONTENT_PACKS, + GATE_ROOM, PIT_RULES, PIT_WORLD, isMob, isPillar, isPit, mobFields, + PATTERNS, + WEATHER, pitFields, playerFields, type PitFields, + type ContentPack, type PitInput } from '../generated/mobpit/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 } from '../shared/scene.ts'; +import { createRing, createStage, material } from '../shared/scene.ts'; import { createBoardWatcher, createMobPitClient, @@ -53,12 +72,14 @@ import { roomZone, TIERS, type BoardSnapshot, + type ShotRecord, type RoomInfo, - type RoomStatus + type RoomStatus, + type TiersAround } 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. */ @@ -82,21 +103,119 @@ 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'); +/** "Wake rooms in reach" (proximity wake), remembered per browser. */ +const WAKE_KEY = 'game-demos.mobpit.wake'; +const wakeInitial = (() => { + try { + return localStorage.getItem(WAKE_KEY) !== 'off'; + } catch { + return true; + } +})(); +const pit = createMobPitClient({ + api, + pinContent, + wake: wakeInitial, + liveVersion: async () => (await pitApi.content()).state?.version ?? '' +}); const { client } = pit; 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). +// The fog is linear and starts at the player, not at the camera: whatever the +// zoom (or first person), the ground around you stays readable and only the +// distance fades. `clearM` is how far past the player it stays clear, `fadeM` +// how much further until the fog is solid. +scene.fogMode = Scene.FOGMODE_LINEAR; +const SKY: Record = { + storm: { clear: '#1c2230', clearM: 12, fadeM: 34, light: 0.7 }, + rain: { clear: '#2e3a4a', clearM: 16, fadeM: 40, light: 0.8 }, + clearing: { clear: '#56708c', clearM: 24, fadeM: 50, light: 0.95 }, + clear: { clear: '#8fc3f0', clearM: 36, fadeM: 70, light: 1.1 } +}; +const lights = scene.lights.map((l) => ({ light: l, base: l.intensity })); +/** Light tint per biome (highlands, steppe, forest, meadow). */ +const BIOME_LIGHT = ['#d6dcea', '#ffe1b3', '#b9dfb4', '#eaf6cf']; +/** Fog and darkness of the current room: its borough's weather × closeness to a boss. */ +function roomSky(): { clear: string; clearM: number; fadeM: 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. Near a boss the fog closes in to + // half the distance and the light drops by a fifth, never to black. + const near = Math.max(0, 1 - distance / 6); + const close = 1 - 0.5 * near; + return { clear: sky.clear, clearM: sky.clearM * close, fadeM: sky.fadeM * close, light: sky.light * (1 - 0.2 * near), near }; +} +let fogClearM = SKY.storm!.clearM; +let fogFadeM = SKY.storm!.fadeM; +function applySky(dtMs: number): void { + const target = roomSky(); + const want = Color4.FromHexString(`${target.clear}ff`).scale(1 - 0.25 * 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); + fogClearM += (target.clearM - fogClearM) * k; + fogFadeM += (target.fadeM - fogFadeM) * k; + // Measured from the camera, so offset by the camera's distance to the player. + const toPlayer = stage.view() === 'first' ? 0 : stage.camera.radius; + scene.fogStart = toPlayer + fogClearM; + scene.fogEnd = scene.fogStart + fogFadeM; + const storm = (pit.world()?.weather ?? 0) === 0; + const flash = storm && Math.random() < 0.004 ? 1.8 : 1; + for (const { light, base } of lights) light.intensity += (base * target.light * flash - light.intensity) * Math.min(1, k * (flash > 1 ? 20 : 1)); + // The room's biome tints the light. + const here = parseRoom(pit.room() ?? DEFAULT_ZONE); + if (here !== undefined && pit.pures.loaded()) { + const seed = pit.hello()?.seed ?? PIT_WORLD.defaultSeed; + const tint = Color3.FromHexString(BIOME_LIGHT[pit.pures.pitRoom({ seed, x: here[0], y: here[1] }).biome] ?? '#ffffff'); + for (const { light } of lights) light.diffuse = Color3.Lerp(light.diffuse, tint, k); + } + canvas.dataset.fog = `${fogClearM.toFixed(1)}/${fogFadeM.toFixed(1)}`; +} + 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 · R / click shoot · E dash · Q jump (over a slam) · V first person · C lag compensation · walk through a doorway to the next room · N wake rooms in reach · 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'); +hud.toggle( + 'Wake rooms in reach (doorway neighbours you walk up to)', + wakeInitial, + (on) => { + pit.setWakeRooms(on); + try { + localStorage.setItem(WAKE_KEY, on ? 'on' : 'off'); + } catch { + // Not remembered (private mode); still applied. + } + }, + 'n' +); +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); @@ -114,7 +233,54 @@ factsPanel.innerHTML = `

Durable facts

const graph = factsPanel.querySelector('canvas') as HTMLCanvasElement; const field = (k: string) => factsPanel.querySelector(`[data-k="${k}"]`) as HTMLElement; const deathBanner = div('pit-death', document.body); +// The borough's world event (scheduled by its cell): a banner while it runs. +const eventBanner = div('pit-event', document.body); +eventBanner.style.display = 'none'; +const EVENT_TEXT: Record = { + invasion: 'INVASION — the mobs of this borough come at you from afar and rise again at once', + frenzy: 'FRENZY — the mobs of this borough are 40 % faster', + 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'); @@ -126,8 +292,8 @@ function div(className: string, parent: HTMLElement): HTMLDivElement { // ---- rooms: minimap from the zone status read model, toasts, debug ---- const roomsPanel = div('pit-rooms', document.body); roomsPanel.innerHTML = `

Rooms

- -
■ active ■ dormant ■ asleep ■ never ran
+ +
■ active ■ dormant ■ asleep ■ never ran ┄ watched □ kept awake ▲ you ● players ● mobs
`; const roomsMap = roomsPanel.querySelector('canvas') as HTMLCanvasElement; const roomsField = (k: string) => roomsPanel.querySelector(`[data-k="${k}"]`) as HTMLElement; @@ -175,37 +341,202 @@ 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(); + +/** A dot on the minimap, in world metres (room (x, y) is centred on (x, y) × ROOM_SIZE). */ +type MiniDot = { wx: number; wy: number; kind: 'me' | 'player' | 'mob'; tier: number; face: number; dead: boolean }; +const miniDots: MiniDot[] = []; +let miniMe: MiniDot | undefined; +/** Metres across the minimap; the mouse wheel zooms. */ +let miniSpanM = 4 * ROOM_SIZE; +roomsMap.addEventListener( + 'wheel', + (event) => { + event.preventDefault(); + miniSpanM = Math.min(12 * ROOM_SIZE, Math.max(1.5 * ROOM_SIZE, miniSpanM * Math.exp(event.deltaY * 0.0015))); + }, + { passive: false } +); +const roomLayouts = new Map(); +function roomLayout(seed: number, x: number, y: number): { biome: number; obstacles: { x: number; y: number; r: number }[] } | undefined { + if (!pit.pures.loaded()) return undefined; + const key = `${seed}:${x}:${y}`; + let layout = roomLayouts.get(key); + if (layout === undefined) { + const room = pit.pures.pitRoom({ seed, x, y }); + layout = { biome: room.biome, obstacles: room.obstacles.map(({ x, y, r }) => ({ x, y, r })) }; + if (roomLayouts.size > 400) roomLayouts.clear(); + roomLayouts.set(key, layout); + } + return layout; +} +const mod = (n: number, m: number) => ((n % m) + m) % m; + +/** + * The minimap: a top-down map that follows you every frame. Rooms show their + * biome floor, walls with doorway gaps and obstacles; the room's state (zone + * status read model) is the tile's border; you, other players and mobs are + * dots at their live positions (your room and the watched rooms next door). + * A watched neighbour has a dashed cyan border; one you keep awake + * (proximity wake) a solid one. + */ function drawRooms(): void { const ctx = roomsMap.getContext('2d'); const here = parseRoom(pit.room() ?? DEFAULT_ZONE); if (ctx === null || here === undefined) return; - const { width } = roomsMap; - const span = 7; - const cell = width / span; - ctx.clearRect(0, 0, width, width); + const { width, height } = roomsMap; + const s = width / miniSpanM; + // Centre on you (between frames of a handoff: your room's centre). + const cx = miniMe?.wx ?? here[0] * ROOM_SIZE; + const cy = miniMe?.wy ?? here[1] * ROOM_SIZE; + const toMap = (wx: number, wy: number): [number, number] => [width / 2 + (wx - cx) * s, height / 2 - (wy - cy) * s]; + ctx.fillStyle = '#0b0d12'; + ctx.fillRect(0, 0, width, height); const seed = pit.hello()?.seed ?? PIT_WORLD.defaultSeed; - for (let j = 0; j < span; j++) { - for (let i = 0; i < span; i++) { - const x = here[0] + i - 3; - const y = here[1] + (span - 1 - j) - 3; - const zone = roomZone(x, y); + const reach = Math.ceil(miniSpanM / ROOM_SIZE / 2) + 1; + const rx0 = Math.round(cx / ROOM_SIZE); + const ry0 = Math.round(cy / ROOM_SIZE); + const door = PIT_WORLD.doorHalf; + // Node names only say something when the rooms run on more than one node. + const manyNodes = new Set([...rooms.values()].map((r) => r.node).filter((n) => n !== '')).size > 1; + ctx.font = '10px monospace'; + for (let ry = ry0 - reach; ry <= ry0 + reach; ry++) { + for (let rx = rx0 - reach; rx <= rx0 + reach; rx++) { + const zone = roomZone(rx, ry); const row = rooms.get(zone); - let color = '#20252e'; - if (row !== undefined) color = STATUS_COLOR[row.status] ?? color; - else if (pit.pures.loaded()) color = `${BIOME_COLOR[pit.pures.pitRoom({ seed, x, y }).biome] ?? '#333'}55`; - ctx.fillStyle = color; - ctx.fillRect(i * cell + 2, j * cell + 2, cell - 4, cell - 4); - if (x === here[0] && y === here[1]) { - ctx.strokeStyle = '#ffffff'; + const layout = roomLayout(seed, rx, ry); + const [x0, y0] = toMap(rx * ROOM_SIZE - HALF, ry * ROOM_SIZE + HALF); + const size = HALF * 2 * s; + // Floor: biome colour, dimmer for rooms that never ran. + ctx.fillStyle = `${BIOME_COLOR[layout?.biome ?? 0] ?? '#333'}${row === undefined ? '33' : '66'}`; + ctx.fillRect(x0, y0, size, size); + // Obstacles. + if (layout !== undefined && s > 0.6) { + ctx.fillStyle = '#0b0d12aa'; + for (const o of layout.obstacles) { + const [ox, oy] = toMap(rx * ROOM_SIZE + o.x, ry * ROOM_SIZE + o.y); + ctx.beginPath(); + ctx.arc(ox, oy, Math.max(1, o.r * s), 0, Math.PI * 2); + ctx.fill(); + } + } + // Walls with a doorway gap in the middle of each. + ctx.strokeStyle = '#9aa3b2'; + ctx.lineWidth = 1; + const wall = (ax: number, ay: number, bx: number, by: number) => { + ctx.beginPath(); + ctx.moveTo(...toMap(rx * ROOM_SIZE + ax, ry * ROOM_SIZE + ay)); + ctx.lineTo(...toMap(rx * ROOM_SIZE + bx, ry * ROOM_SIZE + by)); + ctx.stroke(); + }; + for (const sign of [-1, 1]) { + wall(-HALF, sign * HALF, -door, sign * HALF); + wall(door, sign * HALF, HALF, sign * HALF); + wall(sign * HALF, -HALF, sign * HALF, -door); + wall(sign * HALF, door, sign * HALF, HALF); + } + // Room state as the tile's inner border. + const status = row === undefined ? undefined : STATUS_COLOR[row.status]; + if (status !== undefined) { + ctx.strokeStyle = status; ctx.lineWidth = 2; - ctx.strokeRect(i * cell + 1, j * cell + 1, cell - 2, cell - 2); + ctx.strokeRect(x0 + 3, y0 + 3, size - 6, size - 6); + } + const watched = pit.neighbours().get(zone); + if (pit.near().has(zone)) { + ctx.strokeStyle = '#4dd0e1'; + ctx.lineWidth = 2; + ctx.strokeRect(x0 + 6, y0 + 6, size - 12, size - 12); + } else if (watched === 'watching' && !(rx === here[0] && ry === here[1])) { + ctx.strokeStyle = '#4dd0e1'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.strokeRect(x0 + 6, y0 + 6, size - 12, size - 12); + ctx.setLineDash([]); + } + if (size > 36) { + ctx.fillStyle = '#e6e9ef'; + if (row !== undefined && row.players > 0) ctx.fillText(`${row.players}p`, x0 + 6, y0 + 14); + if (debug && manyNodes && row !== undefined && row.node !== '') ctx.fillText(row.node.replace(/^(celld-|node-)/, ''), x0 + 6, y0 + size - 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] === rx && h.bossRoom[1] === ry); + if (hood !== undefined) { + const [bx, by] = toMap(rx * ROOM_SIZE, ry * ROOM_SIZE); + ctx.fillStyle = hood.bossDown ? '#777' : '#ff2d55'; + ctx.beginPath(); + ctx.arc(bx, by, Math.max(4, 3 * s), 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#fff'; + ctx.fillText(`T${hood.tier}`, bx + 7, by - 5); } - ctx.fillStyle = '#0e1116'; - 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); } } + // Hood (3 × 3 rooms) and borough (9 × 9) borders: the left / bottom wall of + // every room whose index is a multiple of 3 (9). + for (let r = rx0 - reach; r <= rx0 + reach; r++) { + if (mod(r, 3) !== 0) continue; + const borough = mod(r, 9) === 0; + const [x] = toMap(r * ROOM_SIZE - HALF, 0); + ctx.strokeStyle = borough ? '#ffd166' : '#8a6dd1'; + ctx.lineWidth = borough ? 2.5 : 1.25; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, height); + ctx.stroke(); + } + for (let r = ry0 - reach; r <= ry0 + reach; r++) { + if (mod(r, 3) !== 0) continue; + const borough = mod(r, 9) === 0; + const [, y] = toMap(0, r * ROOM_SIZE - HALF); + ctx.strokeStyle = borough ? '#ffd166' : '#8a6dd1'; + ctx.lineWidth = borough ? 2.5 : 1.25; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + } + // Your interest ring. + const viewRadius = pit.hello()?.viewRadius; + if (miniMe !== undefined && viewRadius !== undefined) { + ctx.strokeStyle = '#ffffff22'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(width / 2, height / 2, viewRadius * s, 0, Math.PI * 2); + ctx.stroke(); + } + // Mobs, other players, then you on top. + for (const d of miniDots) { + const [x, y] = toMap(d.wx, d.wy); + if (x < -4 || y < -4 || x > width + 4 || y > height + 4) continue; + ctx.fillStyle = d.kind === 'mob' ? (d.tier >= 4 ? '#ff2d55' : '#ff8a65') : d.dead ? '#666' : '#64b5f6'; + ctx.beginPath(); + ctx.arc(x, y, d.kind === 'mob' ? (d.tier >= 4 ? 3.5 : 2) : 3, 0, Math.PI * 2); + ctx.fill(); + } + if (miniMe !== undefined) { + const [x, y] = toMap(miniMe.wx, miniMe.wy); + const f = miniMe.face; + const tip = (a: number, r: number): [number, number] => [x + Math.cos(f + a) * r, y - Math.sin(f + a) * r]; + ctx.fillStyle = miniMe.dead ? '#888' : '#ffffff'; + ctx.strokeStyle = '#0b0d12'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(...tip(0, 8)); + ctx.lineTo(...tip(2.5, 5)); + ctx.lineTo(...tip(-2.5, 5)); + ctx.closePath(); + ctx.stroke(); + ctx.fill(); + } } function renderDebug(): void { @@ -237,12 +568,26 @@ 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(); 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. @@ -307,7 +652,29 @@ const templates = (() => { pillar.material = mats.pillar; pillar.isPickable = false; pillar.setEnabled(false); - return { floors, wall, segment, gate, pillar }; + // Obstacle looks of the seeded layouts: 0 column · 1 boulder · 2 tree · 3 crystal. + const boulder = MeshBuilder.CreateSphere('room-boulder', { diameter: 1, segments: 6 }, scene); + boulder.material = material(scene, 'boulder', '#6e6a62'); + boulder.isPickable = false; + boulder.setEnabled(false); + const tree = MeshBuilder.CreateCylinder('room-tree', { diameterTop: 0, diameterBottom: 1.6, height: 3.2, tessellation: 7 }, scene); + tree.material = material(scene, 'tree', '#2f6b3a'); + tree.isPickable = false; + tree.setEnabled(false); + const crystalMat = material(scene, 'crystal', '#7fe3ff', 0.85); + crystalMat.emissiveColor = Color3.FromHexString('#2a6f88'); + const crystal = MeshBuilder.CreateBox('room-crystal', { size: 1 }, scene); + crystal.material = crystalMat; + crystal.isPickable = false; + crystal.setEnabled(false); + // Walls take the biome's tone. + const walls = BIOME_COLOR.map((color, biome) => { + const w = wall.clone(`room-wall-${biome}`); + w.material = material(scene, `room-wall-${biome}`, Color3.FromHexString(color).scale(0.55).toHexString()); + w.setEnabled(false); + return w; + }); + return { floors, wall, walls, segment, gate, pillar, boulder, tree, crystal }; })(); type Chunk = { room: Room; node: TransformNode; biome: number | undefined; seed: number }; @@ -332,17 +699,32 @@ function createChunk(room: Room): Chunk { // the floor (the neighbour's wall stands right behind it). const off = PIT_WORLD.doorHalf + templates.segment / 2; const out = HALF + WALL_T / 2; + const wall = templates.walls[biome ?? 0] ?? templates.wall; for (const side of [-1, 1]) { for (const along of [-1, 1]) { - add(templates.wall, `wall-h${side}${along}`, along * off, side * out, WALL_H / 2); - add(templates.wall, `wall-v${side}${along}`, side * out, along * off, WALL_H / 2, Math.PI / 2); + add(wall, `wall-h${side}${along}`, along * off, side * out, WALL_H / 2); + add(wall, `wall-v${side}${along}`, side * out, along * off, WALL_H / 2, Math.PI / 2); } add(templates.gate, `door-h${side}`, 0, side * HALF, 0.04); add(templates.gate, `door-v${side}`, side * HALF, 0, 0.04, Math.PI / 2); } - PILLARS.forEach(({ x, y, r }, i) => { - const instance = add(templates.pillar, `pillar-${i}`, x, y, 1.2); - instance.scaling.set(r * 2, 1, r * 2); + // The room's seeded obstacles (what the step collides with). + const obstacles = pit.pures.loaded() ? pit.pures.pitRoom({ seed, x: room[0], y: room[1] }).obstacles : PILLARS.map((p) => ({ ...p, look: 0 })); + obstacles.forEach(({ x, y, r, look }, i) => { + if (look === 1) { + const instance = add(templates.boulder, `obstacle-${i}`, x, y, r * 0.55); + instance.scaling.set(r * 2, r * 1.3, r * 2); + } else if (look === 2) { + const instance = add(templates.tree, `obstacle-${i}`, x, y, 1.6); + instance.scaling.set(r * 1.25, 1, r * 1.25); + } else if (look === 3) { + const instance = add(templates.crystal, `obstacle-${i}`, x, y, r); + instance.scaling.set(r * 1.4, r * 2, r * 1.4); + instance.rotation.y = 0.6; + } else { + const instance = add(templates.pillar, `pillar-${i}`, x, y, 1.2); + instance.scaling.set(r * 2, 1, r * 2); + } }); return { room, node, biome, seed }; } @@ -412,10 +794,20 @@ function showRoom(zone: string): void { const seed = pit.hello()?.seed ?? PIT_WORLD.defaultSeed; if (pit.pures.loaded()) { const room = pit.pures.pitRoom({ seed, x: at[0], y: at[1] }); - hud.row('Room', `${zone} · ${PIT_WORLD.biomes[room.biome] ?? '?'} · danger ${room.danger} · ${room.mobs.length} mobs`); + hud.row('Room', `${zone} · ${PIT_WORLD.biomes[room.biome] ?? '?'} · ${PATTERNS[room.pattern] ?? '?'} · danger ${room.danger} · ${room.mobs.length} mobs${room.boss ? ' · BOSS ROOM' : ''}`); } } +// ---- 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; @@ -445,7 +837,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; @@ -500,7 +894,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); @@ -515,6 +910,18 @@ 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; +let lastEvent = ''; +pit.on('world', (w, zoneId) => { + if (zoneId !== pit.room()) return; + if (w.event !== lastEvent && w.event !== '') toast(`World event in borough ${w.boroughX},${w.boroughY}: ${w.event}`); + lastEvent = w.event; + 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 = ''; @@ -579,19 +986,77 @@ 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) + 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); commandFrames.update(now, sendCommandFrame); const here = pit.room(); const hereRoom = here === undefined ? undefined : parseRoom(here); @@ -599,6 +1064,7 @@ stage.onFrame((now, dtMs) => { const hello = pit.hello(); const authority = client.authority; const rendered = client.render(); + miniDots.length = 0; const seen = new Set(); let me: { at: Vector3; hp: number; dead: boolean } | undefined; let stats: PitFields | undefined; @@ -616,10 +1082,23 @@ stage.onFrame((now, dtMs) => { const visual = visualFor(entity, mine); seen.add(entity.key); const visible = !entity.stale; + const entityRoom = parseRoom(entity.zoneId); + if (entityRoom !== undefined && visible) { + const base = { wx: entityRoom[0] * ROOM_SIZE, wy: entityRoom[1] * ROOM_SIZE }; + if (visual.kind === 'mob') { + const m = mobFields(entity); + miniDots.push({ wx: base.wx + m.x, wy: base.wy + m.y, kind: 'mob', tier: visual.tier, face: m.face, dead: false }); + } else { + const p = playerFields(entity); + const dot: MiniDot = { wx: base.wx + p.x, wy: base.wy + p.y, kind: mine ? 'me' : 'player', tier: 0, face: p.face, dead: p.dead }; + if (mine) miniMe = dot; + else miniDots.push(dot); + } + } 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; @@ -640,15 +1119,15 @@ stage.onFrame((now, dtMs) => { for (const m of [visual.body, visual.hpBack, visual.hpFill]) m.setEnabled(visible); } else { const player = playerFields(entity); - const { x, y, dead, face, swing } = player; - visual.body.position.copyFrom(at(x, y, 0.9)); + const { x, y, z, dead, face, swing } = player; + visual.body.position.copyFrom(at(x, y, 0.9 + z)); visual.body.material = dead ? mats.dead : mine ? mats.me : mats.other; - visual.nose.position.copyFrom(at(x + Math.cos(face) * 0.5, y + Math.sin(face) * 0.5, 1.3)); + visual.nose.position.copyFrom(at(x + Math.cos(face) * 0.5, y + Math.sin(face) * 0.5, 1.3 + z)); visual.nose.rotation.y = -face; // Blade sweeps through the swing cone while `swing` counts down to 0. const sweep = swing > 0 ? ((SWING_FRAMES - swing) / SWING_FRAMES) * 2 * SWING_HALF - SWING_HALF : -Math.PI / 2.5; const a = face + sweep; - visual.blade.position.copyFrom(at(x + Math.cos(a) * 0.8, y + Math.sin(a) * 0.8, 1.0)); + visual.blade.position.copyFrom(at(x + Math.cos(a) * 0.8, y + Math.sin(a) * 0.8, 1.0 + z)); visual.blade.rotation.y = -a; visual.blade.setEnabled(visible && !dead); visual.arc.setEnabled(visible && swing > 0); @@ -658,9 +1137,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 }; } } @@ -677,7 +1159,24 @@ 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. + 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 + g.z)); + 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 }); @@ -691,6 +1190,40 @@ 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`); + 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 awake = [...pit.near()]; + hud.row('Wake rooms in reach (N)', pit.wakeRooms() ? `on · ${awake.length === 0 ? 'no doorway in reach' : `keeping ${awake.join(', ')} awake`}` : 'off (every neighbour a passive watcher)'); + 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); + const left = w === undefined || w.event === '' ? 0 : Math.max(0, Math.round((w.eventUntilMs - Date.now()) / 1000)); + 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`); const profile = pit.profile(); if (profile !== undefined) { const span = Math.max(1, profile.nextLevelXp - profile.levelXp); @@ -703,8 +1236,8 @@ stage.onFrame((now, dtMs) => { field('saved').textContent = String(savedCount); field('note').textContent = 'Kills settle in your Crawler cell next to the room on celld; the tick never waits for them.'; if (here !== undefined) showRoom(here); + drawRooms(); if (now - lastSampleAt > 250) { - drawRooms(); renderDebug(); lastSampleAt = now; samples.push({ pending, tick: tickMs }); @@ -722,6 +1255,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 bdec1b330..748aa1a28 100644 --- a/examples/game/client/src/mobpit/style.css +++ b/examples/game/client/src/mobpit/style.css @@ -123,7 +123,7 @@ position: fixed; right: 12px; bottom: 16px; - width: 234px; + width: 280px; padding: 10px 12px; border-radius: 8px; background: var(--panel); @@ -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; @@ -176,3 +179,91 @@ .label.door { color: #4dd0e1; } + +.pit-event { + position: fixed; + top: 12px; + left: 50%; + transform: translateX(-50%); + padding: 8px 16px; + border-radius: 6px; + background: rgba(120, 30, 160, 0.85); + color: #fff; + font-weight: 600; + 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; +} + +.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; +} +.pit-rooms .c-me { + color: #ffffff; +} +.pit-rooms .c-other { + color: #64b5f6; +} +.pit-rooms .c-mob { + color: #ff8a65; +} diff --git a/examples/game/client/src/ripple/client.ts b/examples/game/client/src/ripple/client.ts deleted file mode 100644 index 410156046..000000000 --- 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 39c7e09cd..000000000 --- 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/arena/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 80812c189..000000000 --- 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/src/shared/hud.ts b/examples/game/client/src/shared/hud.ts index 50cd10586..b099c8b71 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/input.ts b/examples/game/client/src/shared/input.ts index 8600cd519..fc1be72a1 100644 --- a/examples/game/client/src/shared/input.ts +++ b/examples/game/client/src/shared/input.ts @@ -15,6 +15,8 @@ export type Sample = Readonly<{ action: boolean; /** Secondary action: E / F / gamepad B or LB. */ alt: boolean; + /** Jump: Q / C / gamepad Y. */ + jump: boolean; }>; const UP = ['KeyW', 'ArrowUp']; @@ -23,6 +25,7 @@ const LEFT = ['KeyA', 'ArrowLeft']; const RIGHT = ['KeyD', 'ArrowRight']; const ACTION = ['Space', 'ShiftLeft', 'ShiftRight']; const ALT = ['KeyE', 'KeyF']; +const JUMP = ['KeyQ', 'KeyC']; const DEADZONE = 0.15; export class InputSampler { @@ -56,6 +59,7 @@ export class InputSampler { let y = (this.#any(UP) ? 1 : 0) - (this.#any(DOWN) ? 1 : 0); let action = this.#any(ACTION); let alt = this.#any(ALT); + let jump = this.#any(JUMP); const pad = firstGamepad(); if (pad !== undefined) { const gx = pad.axes[0] ?? 0; @@ -66,13 +70,14 @@ export class InputSampler { } action ||= pad.buttons[0]?.pressed === true || pad.buttons[5]?.pressed === true; alt ||= pad.buttons[1]?.pressed === true || pad.buttons[4]?.pressed === true; + jump ||= pad.buttons[3]?.pressed === true; } const length = Math.hypot(x, y); if (length > 1) { x /= length; y /= length; } - return { x, y, action, alt }; + return { x, y, action, alt, jump }; } dispose(): void { diff --git a/examples/game/client/src/shared/latest.ts b/examples/game/client/src/shared/latest.ts index 504402ed2..7147cc631 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/src/shared/scene.ts b/examples/game/client/src/shared/scene.ts index 8d130149a..a0b282b9b 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/arena-headless.test.mjs b/examples/game/client/tests/arena-headless.test.mjs deleted file mode 100644 index 11fe2657c..000000000 --- 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/freeze-headless.test.mjs b/examples/game/client/tests/freeze-headless.test.mjs deleted file mode 100644 index 457690c5b..000000000 --- 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/client/tests/gallery-headless.test.mjs b/examples/game/client/tests/gallery-headless.test.mjs deleted file mode 100644 index 5164f4581..000000000 --- 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/client/tests/grid-headless.test.mjs b/examples/game/client/tests/grid-headless.test.mjs deleted file mode 100644 index d29b0f4af..000000000 --- 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/helpers/cluster.mjs b/examples/game/client/tests/helpers/cluster.mjs deleted file mode 100644 index d12d0f6a7..000000000 --- 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/helpers/mobpit.mjs b/examples/game/client/tests/helpers/mobpit.mjs index f180080ff..94458ff72 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-bosses-headless.test.mjs b/examples/game/client/tests/mobpit-bosses-headless.test.mjs new file mode 100644 index 000000000..baddfb933 --- /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/mobpit-content-headless.test.mjs b/examples/game/client/tests/mobpit-content-headless.test.mjs new file mode 100644 index 000000000..4bb09e672 --- /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/mobpit-doorway-bow-headless.test.mjs b/examples/game/client/tests/mobpit-doorway-bow-headless.test.mjs new file mode 100644 index 000000000..7db8ca38b --- /dev/null +++ b/examples/game/client/tests/mobpit-doorway-bow-headless.test.mjs @@ -0,0 +1,102 @@ +/** + * Headless Mob Pit: a doorway into a sleeping room right after a fight + * with the bow (Node, rooms on their own `celld dev`). + * + * The player hunts with arrows in first person (every frame carries the + * aim, some loose an arrow), walks into the next room, waits until the room + * it left is asleep, and walks back into it. Prediction must stay exact + * the whole way: no correction of the own player, and the drawn position + * never moves more than one tick's walk between frames — across both + * doorways too. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { BUTTON, isMob, mobFields, PIT_RULES, 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 { largestStep, startPit, walkThroughDoor } from './helpers/mobpit.mjs'; + +/** One tick's walk (5 m/s at 30 Hz) plus a dash frame's worth of slack. */ +const STEP = 0.5; + +/** Hunt with the bow for `ms`: chase the nearest mob, aim at it in first person, loose arrows. */ +async function bowFight(pit, ms) { + const deadline = Date.now() + ms; + let shots = 0; + while (Date.now() < deadline) { + 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(mobFields) + .sort((a, b) => Math.hypot(a.x - at.x, a.y - at.y) - Math.hypot(b.x - at.x, b.y - at.y))[0]; + if (mob === undefined || at.dead) { + pit.client.sendInput({ mx: 0, my: 0, buttons: 0 }); + continue; + } + const d = Math.hypot(mob.x - at.x, mob.y - at.y); + const yaw = Math.atan2(mob.y - at.y, mob.x - at.x); + const pitch = Math.atan2((PIT_RULES.mobHeights[mob.tier] ?? 1) / 2 - (at.z + PIT_RULES.eyeHeight), d); + // Keep some distance and shoot; first person faces the aim. + // (Away from the walls: the fight stays in this room.) + const inside = Math.abs(at.x) < 14 && Math.abs(at.y) < 14; + const away = d < 6 ? -1 : d > 10 ? 1 : 0; + const centre = Math.atan2(-at.y, -at.x); + const move = inside + ? { mx: Math.round(Math.cos(yaw) * 127 * away), my: Math.round(Math.sin(yaw) * 127 * away) } + : { mx: Math.round(Math.cos(centre) * 127), my: Math.round(Math.sin(centre) * 127) }; + const shot = pit.aimShot(yaw, pitch, true, true); + if (shot !== undefined && shots * 300 < ms - (deadline - Date.now())) { + shots += 1; + pit.client.sendInput({ ...move, ...shot.input, buttons: shot.input.buttons }); + } else { + pit.client.sendInput({ ...move, buttons: BUTTON.aim, yaw, pitch: 0, shot: 0, delayMs: 0 }); + } + } + return shots; +} + +test('a doorway into a sleeping room after a bow fight: no correction, no jump', { timeout: 600_000 }, async () => { + const server = await startPit('mobpit'); + const clients = []; + try { + const api = new DevApi({ base: server.httpUrl, name: () => 'Ana', storage: memoryStorage() }); + const pitApi = new MobPitApi(server.httpUrl, server.roomsUrl); + // Proximity wake off: standing at the doorway would keep the room + // behind it awake, and this is about a doorway into a sleeping room. + const pit = createMobPitClient({ api, roomsBase: server.roomsUrl, wake: false }); + clients.push(pit); + const corrections = []; + pit.client.on('correction', (c) => corrections.push({ room: pit.room(), at: Date.now(), m: Math.hypot(c.errors.x ?? 0, c.errors.y ?? 0), errors: c.errors })); + await pit.client.connect('pit.0.0'); + await pit.ready; + await until(() => pit.client.predictor?.active, 5_000, 'bound'); + + for (let round = 1; round <= 3; round += 1) { + const shots = await bowFight(pit, 6_000); + const before = corrections.length; + const east = await walkThroughDoor(pit, [1, 0]); + assert.equal(east.room, 'pit.1.0'); + // The room left goes to sleep (the watcher does not keep it awake). + await eventually(async () => (await pitApi.roomInfo('pit.0.0')).awake === false, 30_000, 'pit.0.0 asleep'); + const west = await walkThroughDoor(pit, [-1, 0]); + assert.equal(west.room, 'pit.0.0'); + await sleep(500); + const fresh = corrections.slice(before).filter((c) => c.m > 0.05); + console.error( + `round ${round}: ${shots} arrows; east step ${largestStep(east.trace).toFixed(3)} m, west step ${largestStep(west.trace).toFixed(3)} m; corrections ${JSON.stringify(fresh.map((c) => ({ room: c.room, m: +c.m.toFixed(3) })))}` + ); + assert.ok(largestStep(east.trace) <= STEP, `east: ${largestStep(east.trace)} m`); + assert.ok(largestStep(west.trace) <= STEP, `west: ${largestStep(west.trace)} m`); + assert.deepEqual(fresh, [], 'no correction of the own player across the doorways'); + } + } finally { + for (const c of clients) c.close(); + await server.stop(); + } +}); diff --git a/examples/game/client/tests/mobpit-events-headless.test.mjs b/examples/game/client/tests/mobpit-events-headless.test.mjs new file mode 100644 index 000000000..25f8dd0a3 --- /dev/null +++ b/examples/game/client/tests/mobpit-events-headless.test.mjs @@ -0,0 +1,57 @@ +/** + * Headless Mob Pit world events (Node): rooms and tier cells on their own + * `celld dev`, the services binary, a scripted player. + * + * The room tells its borough it woke (a zone fact); the borough's cell + * arms its event cycle and, on its own schedule (`ctx.schedule`), starts + * an invasion: the room reads it off the tick and tells its players + * (`world.event`), the services see it in the borough cell, and the effect + * is real — a mob slain during the invasion is back within a second or so + * (normally four). + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { isMob } 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, until } from './helpers/demo-server.mjs'; +import { hunt, startPit } from './helpers/mobpit.mjs'; + +test('a borough schedules a world event; its rooms feel it', { timeout: 600_000 }, async () => { + const server = await startPit('mobpit'); + const clients = []; + try { + const pitApi = new MobPitApi(server.httpUrl, server.roomsUrl); + const pit = createMobPitClient({ + api: new DevApi({ base: server.httpUrl, name: () => 'Eventer', storage: memoryStorage() }), + roomsBase: server.roomsUrl, + neighbours: false + }); + clients.push(pit); + await pit.client.connect('pit.0.0'); + const hello = await pit.ready; + await until(() => pit.world()?.event === 'invasion', 40_000, 'the borough starts an invasion'); + const w = pit.world(); + assert.ok(w.eventUntilMs > Date.now(), 'it runs for a while'); + console.error(`world event: ${w.event} in borough ${w.boroughX},${w.boroughY}, ${Math.round((w.eventUntilMs - Date.now()) / 1000)} s left`); + const tiers = await eventually(async () => { + const t = await pitApi.tiers('pit.0.0'); + return t.boroughs.find((b) => b.borough === '0.0')?.event === 'invasion' && t; + }, 10_000, 'the borough cell shows it'); + assert.ok(tiers); + + // The effect: the fallen rise again at once. + const layout = pit.pures.pitRoom({ seed: hello.seed, x: 0, y: 0 }); + const mobs = () => pit.client.render().filter((e) => isMob(e) && e.zoneId === 'pit.0.0' && !e.stale).length; + let killedAt = 0; + pit.on('kill', () => (killedAt ||= Date.now())); + await hunt(pit, () => killedAt > 0, 45_000); + await until(() => mobs() === layout.mobs.length, 3_000, 'the slain mob is back within seconds'); + console.error(`back after ${Date.now() - killedAt} ms`); + assert.ok(Date.now() - killedAt < 3_500); + } finally { + for (const c of clients) c.close(); + await server.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 000000000..283e85676 --- /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/mobpit-network-headless.test.mjs b/examples/game/client/tests/mobpit-network-headless.test.mjs new file mode 100644 index 000000000..e56b5ccd4 --- /dev/null +++ b/examples/game/client/tests/mobpit-network-headless.test.mjs @@ -0,0 +1,109 @@ +/** + * 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 { BUTTON, 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, z: f.z, vz: f.vz }); + +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'); + + // A jump: up and down, predicted exactly too. + let apex = 0; + for (let i = 0; i < 30; i += 1) { + client.sendInput({ mx: 40, my: 0, buttons: i < 3 ? BUTTON.jump : 0 }); + apex = Math.max(apex, client.predictor.predicted().z); + await sleep(1000 / 30); + } + assert.ok(apex > 1, `jumped (apex ${apex})`); + const landed = stateFromFields(client.predictor.predicted()); + await until(() => client.predictor.pending.length === 0, 5_000, 'acks'); + assert.deepEqual(stateFromFields(client.authority.replica.get(hello.entity).fields), landed, 'the jump agrees bit for bit'); + assert.equal(landed.z, 0, 'back on the floor'); + + // NetSim: the rooms' setting raises the RTT through celld. + const connection = client.authority.connection; + await until(() => connection.clock.synced, 5_000, 'clock'); + // The quietest round trip seen so far (a busy machine spikes single samples). + await sleep(1500); + const base = Math.min(...connection.clock.samples.map((sample) => sample.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 + 100, 20_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/mobpit-ranged-headless.test.mjs b/examples/game/client/tests/mobpit-ranged-headless.test.mjs new file mode 100644 index 000000000..b052f7319 --- /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/tests/mobpit-respawn-headless.test.mjs b/examples/game/client/tests/mobpit-respawn-headless.test.mjs new file mode 100644 index 000000000..8e8bac3b7 --- /dev/null +++ b/examples/game/client/tests/mobpit-respawn-headless.test.mjs @@ -0,0 +1,78 @@ +/** + * Headless Mob Pit: dying and respawning (Node, rooms on their own + * `celld dev`). + * + * The player walks up to a mob and lets it win. The room respawns it at a + * spawn point of the room: the drawn position must cut there in one frame + * (a respawn is a teleport, not a misprediction to smooth over), and no + * frame may slide part of the way — that slide is what a player sees as a + * jump (it was the "~1.5 m jump" walking back into the entrance after a + * fight: a death in the room being left, eased over 150 ms). + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { isMob, mobFields, 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'; + +/** One tick's walk at 5 m/s, with slack for a frame that takes two inputs. */ +const STEP = 0.5; + +test('a respawn cuts to the spawn point in one frame, no slide', { timeout: 600_000 }, async () => { + const server = await startPit('mobpit'); + const clients = []; + try { + const api = new DevApi({ base: server.httpUrl, name: () => 'Victim', storage: memoryStorage() }); + const pit = createMobPitClient({ api, roomsBase: server.roomsUrl, neighbours: false }); + clients.push(pit); + let died = 0; + pit.on('died', () => (died += 1)); + await pit.client.connect('pit.1.0'); + await pit.ready; + await until(() => pit.client.predictor?.active, 5_000, 'bound'); + + // Sample what is drawn at 60 Hz while walking into the nearest mob + // (no swing) until it kills us, then until respawned and settled. + const frames = []; + let respawnedAt; + const deadline = Date.now() + 240_000; + for (let i = 0; ; i += 1) { + assert.ok(Date.now() < deadline, 'killed and respawned in time'); + await sleep(1000 / 60); + const me = pit.me(); + if (me === undefined) continue; + const p = playerFields(me); + frames.push({ t: Date.now(), x: p.x, y: p.y, dead: p.dead, died }); + if (respawnedAt === undefined && died > 0 && !p.dead && frames.some((f) => f.dead)) respawnedAt = frames.length - 1; + if (respawnedAt !== undefined && frames.length - respawnedAt > 60) break; + if (i % 2 !== 0) continue; + // One input per tick: towards the nearest mob, never swinging. + const mob = pit.client + .render() + .filter((e) => isMob(e) && !e.stale && e.zoneId === pit.room()) + .map(mobFields) + .sort((a, b) => Math.hypot(a.x - p.x, a.y - p.y) - Math.hypot(b.x - p.x, b.y - p.y))[0]; + const d = mob === undefined ? 0 : Math.hypot(mob.x - p.x, mob.y - p.y); + const k = d > 1.2 ? 127 / d : 0; + pit.client.sendInput(mob === undefined || p.dead ? { mx: 0, my: 0, buttons: 0 } : { mx: Math.round((mob.x - p.x) * k), my: Math.round((mob.y - p.y) * k), buttons: 0 }); + } + const steps = frames.slice(1).map((f, i) => ({ d: Math.hypot(f.x - frames[i].x, f.y - frames[i].y), dt: f.t - frames[i].t })); + const big = steps.filter((s) => s.d > STEP); + console.error(`died ${died}×; frame steps above ${STEP} m: ${JSON.stringify(big.map((s) => +s.d.toFixed(3)))}`); + assert.ok(big.length >= 1, 'the respawn moved the player'); + assert.equal(big.length, 1, 'one cut to the spawn point, no slide across frames'); + // The cut lands on a spawn point of the room (±4.5, ±4.5 or 0, ±5.5). + const landed = frames[respawnedAt]; + const spots = [[-4.5, -4.5], [4.5, -4.5], [4.5, 4.5], [-4.5, 4.5], [0, -5.5], [0, 5.5]]; + assert.ok( + spots.some(([x, y]) => Math.hypot(landed.x - x, landed.y - y) < 1), + `respawned at a spawn point: ${landed.x.toFixed(2)},${landed.y.toFixed(2)}` + ); + } finally { + for (const c of clients) c.close(); + await server.stop(); + } +}); diff --git a/examples/game/client/tests/mobpit-wake-headless.test.mjs b/examples/game/client/tests/mobpit-wake-headless.test.mjs new file mode 100644 index 000000000..a223ef79f --- /dev/null +++ b/examples/game/client/tests/mobpit-wake-headless.test.mjs @@ -0,0 +1,152 @@ +/** + * Headless Mob Pit proximity wake (Node): rooms on their own `celld dev`. + * + * "Wake rooms in reach": walking toward a doorway, the room behind it + * wakes (its watcher becomes *near*) before the player reaches the doorway + * and stays live; back in the middle of the room it is released after the + * hysteresis and goes dormant, then to sleep. Standing in a corner keeps + * at most the two doorway rooms of that corner awake — the corner + * (diagonal) room is never woken. With the toggle off nothing wakes. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { PIT_WORLD, 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'; + +/** + * Walk to room-local `(tx, ty)` (one command frame per tick), steering + * around the room's obstacles, and record how many rooms were near. + */ +async function walkTo(pit, obstacles, [tx, ty], seen, timeoutMs = 20_000) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const me = pit.me(); + const p = me && me.zoneId === pit.room() ? playerFields(me) : undefined; + seen.max = Math.max(seen.max, pit.near().size); + if (p !== undefined && Math.hypot(tx - p.x, ty - p.y) < 0.4) break; + if (Date.now() > deadline) throw new Error(`walk to ${tx},${ty} timed out at ${p?.x},${p?.y}`); + let input = { mx: 0, my: 0, buttons: 0 }; + if (p !== undefined && !p.dead) { + let dx = tx - p.x; + let dy = ty - p.y; + const len = Math.max(Math.hypot(dx, dy), 1e-3); + dx /= len; + dy /= len; + // Push away from obstacles close ahead (slide around them). + for (const o of obstacles) { + const ox = p.x - o.x; + const oy = p.y - o.y; + const d = Math.hypot(ox, oy); + const clear = d - o.r - PIT_WORLD.playerRadius; + if (clear < 1.5 && ox * dx + oy * dy < 0) { + const k = 0.8 / Math.max(clear, 0.15); + // Sideways around it, on the side the target is. + const side = Math.sign(-oy * (tx - o.x) + ox * (ty - o.y)) || 1; + dx += ((side * -oy) / d) * k + (ox / d) * k * 0.3; + dy += ((side * ox) / d) * k + (oy / d) * k * 0.3; + } + } + const n = Math.max(Math.hypot(dx, dy), 1e-3); + const slow = Math.min(1, len / 1.5); + input = { mx: Math.round((dx / n) * 127 * Math.max(slow, 0.35)), my: Math.round((dy / n) * 127 * Math.max(slow, 0.35)), buttons: 0 }; + } + pit.client.sendInput(input); + await sleep(1000 / 30); + } + pit.client.sendInput({ mx: 0, my: 0, buttons: 0 }); +} + +/** Keep sending idle frames for `ms` (the room keeps simulating us). */ +async function idle(pit, ms, seen) { + const end = Date.now() + ms; + while (Date.now() < end) { + seen.max = Math.max(seen.max, pit.near().size); + pit.client.sendInput({ mx: 0, my: 0, buttons: 0 }); + await sleep(1000 / 30); + } +} + +test('proximity wake: doorway rooms in reach wake, release with hysteresis, corners never, toggle off wakes nothing', { timeout: 600_000 }, async () => { + const server = await startPit('mobpit'); + const clients = []; + try { + const pitApi = new MobPitApi(server.httpUrl, server.roomsUrl); + const status = async (zone) => (await pitApi.rooms()).rooms.find((r) => r.zone === zone)?.status; + const pit = createMobPitClient({ + api: new DevApi({ base: server.httpUrl, name: () => 'Waker', storage: memoryStorage() }), + roomsBase: server.roomsUrl + }); + clients.push(pit); + assert.equal(pit.wakeRooms(), true, 'on by default'); + await pit.client.connect('pit.0.0'); + const hello = await pit.ready; + const obstacles = pit.pures.pitRoom({ seed: hello.seed, x: 0, y: 0 }).obstacles; + const seen = { max: 0 }; + await until(() => [...pit.neighbours().values()].filter((s) => s === 'asleep').length === 8, 15_000, 'neighbours asleep'); + + // The middle of the room: nothing in reach. + await walkTo(pit, obstacles, [-3, 3], seen); + await idle(pit, 1000, seen); + assert.equal(pit.near().size, 0, 'nothing from the middle'); + + // Toward the east doorway, short of it (7 m away, still in pit.0.0). + await walkTo(pit, obstacles, [13, 2.5], seen); + assert.equal(pit.room(), 'pit.0.0', 'not through the doorway'); + await until(() => pit.near().has('pit.1.0') && pit.near().size === 1, 5_000, 'pit.1.0 in reach'); + await eventually(async () => (await pitApi.roomInfo('pit.1.0')).ticking === true, 15_000, 'pit.1.0 woke'); + await eventually(async () => (await status('pit.1.0')) === 'active', 15_000, 'pit.1.0 active (zone_status)'); + const woken = await pitApi.roomInfo('pit.1.0'); + assert.equal(woken.players, 0, 'kept awake by the near watcher, no player'); + assert.equal(woken.near, 1); + await until(() => pit.neighbours().get('pit.1.0') === 'watching', 10_000, 'watching pit.1.0'); + // Stays live past the hibernation delay (1.5 s here). + await idle(pit, 3000, seen); + assert.equal((await pitApi.roomInfo('pit.1.0')).ticking, true, 'kept awake'); + console.error(`pit.1.0 woke at 7 m from its doorway (reach ${PIT_WORLD.wakeReach} m, release ${PIT_WORLD.wakeRelease} m)`); + + // Back to the middle: released (beyond the hysteresis), dormant, asleep. + await walkTo(pit, obstacles, [-3, 3], seen); + await until(() => pit.near().size === 0, 5_000, 'released'); + await eventually(async () => (await status('pit.1.0')) === 'dormant' || (await status('pit.1.0')) === 'hibernated', 15_000, 'pit.1.0 dormant'); + await eventually(async () => (await status('pit.1.0')) === 'hibernated', 15_000, 'pit.1.0 asleep again'); + + // A corner: east, then north doorway — both in reach (hysteresis), the + // corner room pit.1.1 never. + await walkTo(pit, obstacles, [15, 3], seen); + await walkTo(pit, obstacles, [5, 6], seen); + await walkTo(pit, obstacles, [5, 11], seen); + await until(() => pit.near().has('pit.1.0') && pit.near().has('pit.0.1'), 5_000, 'two doorways in reach'); + await walkTo(pit, obstacles, [17, 17], seen); + await idle(pit, 2500, seen); + assert.deepEqual([...pit.near()].sort(), ['pit.0.1', 'pit.1.0'], 'the two doorway rooms of the corner'); + await eventually(async () => (await pitApi.roomInfo('pit.0.1')).ticking === true, 15_000, 'pit.0.1 woke'); + await eventually(async () => (await pitApi.roomInfo('pit.1.0')).ticking === true, 15_000, 'pit.1.0 woke'); + const corner = await pitApi.roomInfo('pit.1.1'); + assert.equal(corner.awake, false, 'the corner room is never woken'); + assert.equal(corner.wakes, 0); + assert.ok(seen.max <= 2, `at most two rooms near at once (saw ${seen.max})`); + + // Toggle off: both released, they go to sleep; walking up to a doorway + // wakes nothing. + pit.setWakeRooms(false); + assert.equal(pit.near().size, 0); + await eventually(async () => (await status('pit.1.0')) === 'hibernated' && (await status('pit.0.1')) === 'hibernated', 20_000, 'both asleep with the toggle off'); + const before = (await pitApi.roomInfo('pit.1.0')).wakes; + await walkTo(pit, obstacles, [15, 3], seen); + await walkTo(pit, obstacles, [13, 2.5], seen); + await idle(pit, 3000, seen); + assert.equal(pit.near().size, 0, 'off: nothing near'); + const after = await pitApi.roomInfo('pit.1.0'); + assert.equal(after.awake, false, 'off: the doorway room stays asleep'); + assert.equal(after.wakes, before); + assert.equal((await pitApi.roomInfo('pit.1.1')).wakes, 0, 'the corner room never woke'); + console.error(`proximity wake: at most ${seen.max} rooms near at once`); + } 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 faa17c48b..16c9d65f6 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(), ['arena', 'freeze', 'grid', 'mobpit', 'ripple']); + 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\``); @@ -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,36 @@ 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 ['arena', 'grid']) { + 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; - if (name === 'grid') prediction.setContext({ seed: 0x00c0ffee, cx: 3, cy: 2 }); + // Mob Pit's step reads its room (the context): the entrance here. + const context = generated.PREDICT_CONTEXT === undefined ? undefined : { seed: generated.PIT_WORLD.defaultSeed, x: 0, y: 0 }; + if (context !== undefined) prediction.setContext(context); 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 = context === undefined ? new Uint8Array(0) : encodePayload(generated.PREDICT_CONTEXT, context); 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/tests/ripple-headless.test.mjs b/examples/game/client/tests/ripple-headless.test.mjs deleted file mode 100644 index ef43db758..000000000 --- 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/client/vite.config.ts b/examples/game/client/vite.config.ts index 384457ee3..f9ee41f89 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']; 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 0fcd48b59..9c9fabb15 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 @@ -12,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 grid|ripple same 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 @@ -43,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 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 # (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 @@ -65,16 +60,11 @@ 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) -# 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) +DEMOS=(mobpit leaderboard) export MOBPIT_DATA_DIR="${MOBPIT_DATA_DIR:-$HERE/.data/mobpit}" -# Multi-host demos: `game-demos --host-id A|B --peer …`. -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) CLI="$REPO/target/debug/distributed" log() { printf '\033[1;36m[demos]\033[0m %s\n' "$*"; } @@ -123,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 @@ -392,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 @@ -400,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 @@ -458,44 +412,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. @@ -581,8 +497,8 @@ run_sveltekit() { smoke() { ensure_sdk ensure_client - # The celld-backed tests (Mob Pit, The Freeze, grid, ripple) 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 @@ -593,7 +509,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 @@ -621,12 +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 [[ " ${CLUSTER_DEMOS[*]} " == *" $1 "* ]]; then - run_cluster "$1" - elif [[ "$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/Cargo.lock b/examples/game/server/Cargo.lock index 54d7518d7..5dd458ac3 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", ] @@ -1759,12 +1761,25 @@ dependencies = [ "futures-util", "game_demos", "mobpit_crawler", + "mobpit_tiers", "serde", "serde_json", "sqlx", "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 7ee5b13d5..746b1090c 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 0f98f9dc9..bd3ea88a9 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 31212d99b..334458417 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 16b7dacfc..521af3a98 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 { @@ -24,15 +26,96 @@ 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, + // 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), + ..distributed_game::RewindConfig::default() + }, ..HostConfig::default() } } fn fact_routes(sink: ObjectFactSink) -> ObjectFactSink { sink.route(FactRoute::by_principal::(CRAWLER_KIND)) + .route(FactRoute::by_input_field::( + HOOD_KIND, "hood", + )) + .route(FactRoute::by_input_field::( + BOROUGH_KIND, + "borough", + )) .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, + event: b["event"].as_str().unwrap_or_default().to_string(), + event_until_ms: b["event_until_ms"].as_u64().unwrap_or(0), + hoods_down: borough_state["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 51ba98e07..988b0b7b9 100644 --- a/examples/game/server/rooms/src/zone.rs +++ b/examples/game/server/rooms/src/zone.rs @@ -50,17 +50,21 @@ 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, ObserverId, PlayerId, Project, Replicated, View, Zone, }; -use game_sim::arena::{self as movement, MoveState, PILLARS, PLAYER_RADIUS}; +use game_sim::arena::{self as movement, PLAYER_RADIUS}; +use game_sim::mobpit::PitState as MoveState; 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, RoomsInReachFn, + 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}; @@ -68,19 +72,22 @@ pub use game_sim::mobpit::{pit_step, PitInput, PitStep}; /// Entity id of the stats entity (the zone's own HUD view). pub const PIT_ENTITY: EntityId = EntityId(900); -/// A player: the Arena's predicted [`MoveState`] plus server-owned combat +/// A player: the predicted [`MoveState`] (the Arena capsule plus a jump's +/// height, `game_sim::mobpit::PitState`) plus server-owned combat /// state. #[derive(Debug, Clone, PartialEq, Replicated)] #[replicated(kind = "player", id = 1, priority = 2.0, of = Player)] pub struct PlayerView { - /// Position x (room-local; past ±HALF in a doorway passage). + /// Position x (room-local; past ±HALF in a doorway passage). A + /// misprediction eases out over 150 ms; a jump over 2 m (a respawn) is + /// a cut, never a slide. #[field_type(rooms::POS)] - #[correct(ease = 150)] + #[correct(ease = 150, snap_above = 2.0)] #[from(state.x)] pub x: f32, /// Position y. #[field_type(rooms::POS)] - #[correct(ease = 150)] + #[correct(ease = 150, snap_above = 2.0)] #[from(state.y)] pub y: f32, /// Velocity x. @@ -101,6 +108,16 @@ pub struct PlayerView { #[correct(snap)] #[from(state.cd)] pub cd: u8, + /// Height above the floor (a jump). + #[field_type(rooms::HEIGHT)] + #[correct(ease = 100)] + #[from(state.z)] + pub z: f32, + /// Vertical velocity. + #[field_type(movement::VEL)] + #[correct(snap)] + #[from(state.vz)] + pub vz: f32, /// Display name. #[priority(0.2)] #[correct(replace)] @@ -123,6 +140,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). @@ -166,16 +186,16 @@ pub struct Pillar { } impl Pillar { - /// Every pillar of the floor. - pub fn all() -> Vec { - PILLARS + /// The obstacles of a room's seeded layout. + pub fn of(obstacles: &[game_sim::mobpit::Obstacle]) -> Vec { + obstacles .iter() .enumerate() - .map(|(i, &(x, y, r))| Pillar { + .map(|(i, o)| Pillar { id: EntityId(100 + i as u32), - x, - y, - r, + x: o.x, + y: o.y, + r: o.r, }) .collect() } @@ -261,6 +281,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)] @@ -317,6 +339,84 @@ 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 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, + }, + /// 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 { + /// 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, + /// The borough's world event (`invasion`, `frenzy`, `blessing`; empty + /// when none), scheduled by its cell. + pub event: String, + /// When the event ends (Unix ms, 0 when none). + pub event_until_ms: u64, + /// Bosses down across the city. + pub city_bosses_down: u32, + /// The city's banner. + pub banner: String, } /// What services deliver into a room. @@ -335,6 +435,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). @@ -343,8 +449,16 @@ pub fn game_surface() -> GameSurface { .events::() .predict::() .pure::() + .pure::() .constant("pit_rules", &pit_rules()) + .constant("weather", &WEATHER.map(String::from).to_vec()) + .constant( + "patterns", + &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). @@ -362,25 +476,146 @@ 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; +/// 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; 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; +/// 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 { + 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 +} + +/// [`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; + 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"]; @@ -408,6 +643,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. @@ -424,6 +671,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(), } } @@ -451,6 +704,10 @@ 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, + /// The content pack's hit points (percent, for the HP bar). + hp_pct: u16, } struct Player { @@ -466,8 +723,13 @@ 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, + /// Told it must reload at the entrance (it runs older content). + told_stale: bool, } struct PendingKill { @@ -554,6 +816,25 @@ 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, + /// 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, + /// The content pack of the room's content version. + pack: ContentPack, +} + +/// 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 { @@ -577,8 +858,14 @@ fn spawn_point(player: PlayerId) -> (f32, f32) { SPOTS[player.0 as usize % SPOTS.len()] } -fn push_out_of_pillars(x: &mut f32, y: &mut f32, radius: f32) { - for (px, py, pr) in PILLARS { +fn push_out_of_pillars(x: &mut f32, y: &mut f32, radius: f32, pillars: &[Pillar]) { + for &Pillar { + x: px, + y: py, + r: pr, + .. + } in pillars + { let (ox, oy) = (*x - px, *y - py); let d2 = ox * ox + oy * oy; let min = radius + pr; @@ -609,10 +896,10 @@ fn dist(a: (f32, f32), b: (f32, f32)) -> f32 { } /// Carried through a doorway: the player's [`MoveState`] in the -/// neighbour's frame (x y vx vy as f32 LE, dash, cd). +/// neighbour's frame (x y vx vy z vz as f32 LE, dash, cd): a jump carries on. fn encode_carried(s: &MoveState) -> Vec { - let mut out = Vec::with_capacity(18); - for v in [s.x, s.y, s.vx, s.vy] { + let mut out = Vec::with_capacity(26); + for v in [s.x, s.y, s.vx, s.vy, s.z, s.vz] { out.extend_from_slice(&v.to_le_bytes()); } out.extend_from_slice(&[s.dash, s.cd]); @@ -621,7 +908,7 @@ fn encode_carried(s: &MoveState) -> Vec { /// Inverse of [`encode_carried`]; None unless it is a place a player can be. fn decode_carried(bytes: &[u8]) -> Option { - if bytes.len() != 18 { + if bytes.len() != 26 { return None; } let f = |i: usize| f32::from_le_bytes(bytes[i * 4..i * 4 + 4].try_into().expect("4 bytes")); @@ -630,8 +917,10 @@ fn decode_carried(bytes: &[u8]) -> Option { y: f(1), vx: f(2), vy: f(3), - dash: bytes[16], - cd: bytes[17], + z: f(4), + vz: f(5), + dash: bytes[24], + cd: bytes[25], }; admissible(&state).then(|| rooms::canonicalize(state)) } @@ -707,6 +996,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 { @@ -729,6 +1019,53 @@ 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); + } + } + + /// Tell the borough a room of it is awake (a zone fact, once per + /// activation): its cell arms its world event cycle. + fn report_awake(&mut self, ctx: &mut Ctx<'_, Self>) { + let input = mobpit_tiers::RoomAwakeInput { + borough: pair_shard((self.world.borough_x, self.world.borough_y)), + room: ctx.zone_id().to_string(), + }; + let boot = ctx.boot_epoch(); + if let Ok(fact) = ctx.fact_with_key(&input, &[&"awake", &boot]) { + self.awake_reported = ctx.emit_fact(fact).is_ok(); + } + } + + /// The effect of the borough's world event right now. + fn event(&self) -> &str { + self.world.event.as_str() + } + + /// 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 @@ -767,9 +1104,19 @@ 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, + x: self.room.room.0, + y: self.room.room.1, + }; + let blessing = self.world.event == "blessing"; 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. @@ -793,14 +1140,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, &(), movement::DT); + 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; @@ -812,11 +1190,14 @@ impl MobPit { } } p.regen = p.regen.wrapping_add(1); - if p.regen % 15 == 0 && p.hp < PLAYER_HP { + // A blessing (the borough's world event) heals five times as fast. + let every = if blessing { 3 } else { 15 }; + if p.regen % every == 0 && p.hp < PLAYER_HP { p.hp += 1; } ctx.set_view(*id, View::new(p.state.x, p.state.y, VIEW_RADIUS)); } + self.fires = fires; swings } @@ -832,9 +1213,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. @@ -869,22 +1266,128 @@ 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() + 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>) { let dt = ctx.dt_secs(); let tick = ctx.tick(); @@ -894,17 +1397,44 @@ impl MobPit { .filter(|(_, p)| p.dead_until.is_none()) .map(|(id, p)| (*id, p.state.x, p.state.y)) .collect(); + // Who a slam reaches: the ring is on the floor, a jump clears it. + let low: Vec = self + .players + .iter() + .filter(|(_, p)| p.state.z < SLAM_CLEARANCE) + .map(|(id, _)| *id) + .collect(); let mut hits: Vec<(PlayerId, i32)> = Vec::new(); + let difficulty = self.world.hood_tier; + let boss_down = self.world.boss_down; + // 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 } * self.pack.mob_pace; + let aggro = if invasion { AGGRO * 2.0 } else { AGGRO }; 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) = ( + 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 => { - if tick >= m.respawn_at { + // A boss returns only once its hood says so. + // An invasion brings the fallen back after a second. + let due = tick >= m.respawn_at + || (invasion + && m.tier != BOSS_TIER + && tick + RESPAWN_TICKS >= m.respawn_at + 30); + if due && !(m.tier == BOSS_TIER && boss_down) { m.generation += 1; m.hp = max_hp; (m.x, m.y) = m.home; @@ -916,7 +1446,7 @@ impl MobPit { let nearest = living .iter() .map(|(id, x, y)| (*id, dist((m.x, m.y), (*x, *y)))) - .filter(|(_, d)| *d < AGGRO) + .filter(|(_, d)| *d < aggro) .min_by(|a, b| a.1.total_cmp(&b.1)); if let Some((id, _)) = nearest { m.state = MobState::Chase; @@ -939,7 +1469,7 @@ impl MobPit { m.timer = WINDUP_TICKS; m.face = (ty - m.y).atan2(tx - m.x); } else { - m.face = step_toward(&mut m.x, &mut m.y, tx, ty, speed * dt); + m.face = step_toward(&mut m.x, &mut m.y, tx, ty, speed * pace * dt); } } _ => { @@ -951,7 +1481,9 @@ impl MobPit { m.timer = m.timer.saturating_sub(1); if m.timer == 0 { for (id, x, y) in &living { - if dist((m.x, m.y), (*x, *y)) <= tele + PLAYER_RADIUS * 0.5 { + if dist((m.x, m.y), (*x, *y)) <= tele + PLAYER_RADIUS * 0.5 + && low.contains(id) + { hits.push((*id, damage)); } } @@ -968,7 +1500,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; @@ -976,7 +1510,7 @@ impl MobPit { } } let m = &mut self.mobs[i]; - push_out_of_pillars(&mut m.x, &mut m.y, radius); + push_out_of_pillars(&mut m.x, &mut m.y, radius, &self.pillars); } // Light separation so a pack does not stack on one spot. for i in 0..self.mobs.len() { @@ -1079,7 +1613,10 @@ impl MobPit { dead_until: None, level: 1, regen: 0, + fire_cd: 0, + shot_anim: 0, leaving: false, + told_stale: false, }, ); self.greet(ctx, player); @@ -1105,8 +1642,10 @@ impl MobPit { seed: self.room.seed, restored: self.restored, room_kills: self.kills, + view_radius: VIEW_RADIUS, }, ); + ctx.send(player, &PitEvent::World(self.world.clone())); } } @@ -1128,7 +1667,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, @@ -1147,13 +1686,15 @@ impl Zone for MobPit { x: home.0, y: home.1, face: 0.0, - hp: tier_stats(slot.tier).0, + hp: pack_hp(slot.tier, 0, &content_pack(ctx.content_version())), state: MobState::Idle, timer: 0, target: None, generation: 0, respawn_at: 0, wander: home, + difficulty: 0, + hp_pct: content_pack(ctx.content_version()).mob_hp_pct, } }) .collect(); @@ -1161,7 +1702,7 @@ impl Zone for MobPit { room, players: BTreeMap::new(), mobs, - pillars: Pillar::all(), + pillars: Pillar::of(&layout.obstacles), pending: HashMap::new(), backlog: VecDeque::new(), last_tick: None, @@ -1174,6 +1715,20 @@ 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() + } + }, + awake_reported: false, + fires: Vec::new(), + pack: content_pack(ctx.content_version()), } } @@ -1240,8 +1795,24 @@ 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, VIEW_RADIUS)); + ctx.set_view(observer, View::new(0.0, 0.0, WATCH_RADIUS)); } fn decode_input(&self, _player: PlayerId, payload: &[u8]) -> Option { @@ -1250,8 +1821,14 @@ impl Zone for MobPit { fn on_tick(&mut self, ctx: &mut Ctx<'_, Self>) { self.tick = ctx.tick(); + if !self.awake_reported { + self.report_awake(ctx); + } 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); @@ -1274,6 +1851,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())); + } + } } } @@ -1352,6 +1966,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() } @@ -1413,7 +2040,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, tier_stats(m.tier).0); + m.hp = saved + .hp + .clamp(1, pack_hp(m.tier, mobpit_tiers::MAX_TIER, &self.pack)); m.state = MobState::Return; } } @@ -1435,6 +2064,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, }, )) } @@ -1451,7 +2081,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) * 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 { @@ -1460,7 +2090,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, }, @@ -1514,6 +2144,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(), @@ -1543,6 +2178,20 @@ impl MobPit { pub fn player_position(&self, player: PlayerId) -> Option<(f32, f32)> { self.players.get(&player).map(|p| (p.state.x, p.state.y)) } + + /// A player's predicted state and hit points (tests and bots). + pub fn player_state(&self, player: PlayerId) -> Option<(MoveState, i32)> { + self.players.get(&player).map(|p| (p.state, p.hp)) + } + + /// Mobs winding up a slam: `(id, x, y, ticks left)` (tests and bots). + pub fn mob_windups(&self) -> Vec<(u32, f32, f32, u16)> { + self.mobs + .iter() + .filter(|m| m.state == MobState::Windup) + .map(|m| (m.id.get(), m.x, m.y, m.timer)) + .collect() + } } /// Bot payload: the direction as a move plus attack always held. @@ -1552,6 +2201,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 0924bb273..d3c3c3af6 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, @@ -324,3 +326,435 @@ 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 }, + ..PitInput::default() + }; + 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"); +} + +/// Walk `player` toward the nearest living mob until one winds up a slam +/// next to it; then, with `jump`, take off so the slam lands mid-air. +/// Returns the player's HP before and after the slam. +fn face_a_slam(jump: bool) -> (i32, i32) { + let clock = ManualClock::new(); + let mut room = host("pit.0.0", &clock); + let (client, player) = join(&mut room, "ana"); + let mut seq = 0u32; + let mut send = |room: &mut ZoneHost, input: PitInput| { + seq += 1; + room.push_input( + client, + seq, + seq, + &distributed_game::GameInput::to_payload(&input), + ); + }; + for _ in 0..900 { + let (me, hp) = room.zone().player_state(player).expect("in the room"); + if let Some(&(_, _, _, left)) = room.zone().mob_windups().first() { + if left <= 12 { + let before = hp; + send( + &mut room, + PitInput { + buttons: if jump { PitInput::JUMP } else { 0 }, + ..PitInput::default() + }, + ); + // One command frame per tick, as clients send them (the + // step, gravity included, runs per input). + for _ in 0..20 { + tick(&mut room, &clock); + send(&mut room, PitInput::default()); + } + let (_, after) = room.zone().player_state(player).expect("in the room"); + return (before, after); + } + send(&mut room, PitInput::default()); + } else { + let target = 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 (dx, dy) = (target.1 - me.x, target.2 - me.y); + let d = (dx * dx + dy * dy).sqrt().max(1e-3); + let k = if d < 1.6 { 0.0 } else { 127.0 / d }; + send( + &mut room, + PitInput { + mx: (dx * k) as i8, + my: (dy * k) as i8, + buttons: 0, + ..PitInput::default() + }, + ); + } + tick(&mut room, &clock); + } + panic!("no slam"); +} + +#[test] +fn a_jump_clears_a_mobs_slam() { + let (before, after) = face_a_slam(false); + assert!( + after < before, + "standing in the ring hurts: {before} → {after}" + ); + let (before, after) = face_a_slam(true); + assert!( + after >= before, + "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}" + ); +} + +/// 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/service/Cargo.toml b/examples/game/server/service/Cargo.toml index 7c664acb8..b525f0b8f 100644 --- a/examples/game/server/service/Cargo.toml +++ b/examples/game/server/service/Cargo.toml @@ -21,6 +21,7 @@ path = "src/main.rs" [dependencies] mobpit_crawler = { path = "../crawler" } +mobpit_tiers = { path = "../tiers" } # `redis`: the Redis leaderboards the zone server writes, served as GraphQL # board roots (`xpLeaderboard`, `killsMyRank`, … with `@live`). distributed = { path = "../../../..", features = ["sqlite", "http", "graphql", "redis"] } diff --git a/examples/game/server/service/src/lib.rs b/examples/game/server/service/src/lib.rs index c7bdd0841..f2074c01f 100644 --- a/examples/game/server/service/src/lib.rs +++ b/examples/game/server/service/src/lib.rs @@ -57,9 +57,32 @@ pub use mobpit_crawler::stats::{ projector, PlayerStats, SavePlayerStats, APPLICATION, EPOCH, PLAYER_STATS, PROJECTOR, }; -/// Both tables: `player_stats` and `zone_activities`. +/// The boroughs (bosses down, weather, the world event running) and their +/// projection, from the borough cells' domain events. +pub use mobpit_tiers::events::{Boroughs, BOROUGHS}; + +/// The tables: `player_stats`, `zone_activities` and `boroughs`. pub fn read_model_catalog() -> distributed::ReadModelCatalog { - mobpit_crawler::stats::read_model_catalog().table_schema(ZoneActivities::schema().clone()) + mobpit_crawler::stats::read_model_catalog() + .table_schema(ZoneActivities::schema().clone()) + .table_schema(Boroughs::schema().clone()) +} + +/// The projector of `boroughs`, owned by this application. +pub fn boroughs_projector() -> distributed::graphql::SurfaceProjector { + mobpit_tiers::events::projector(APPLICATION) +} + +/// The boroughs (and their world events) are public to signed-in players. +pub fn borough_permissions() -> ModelPermissions { + ModelPermissions::new().grant(ROLE, read().all_columns()) +} + +async fn project_boroughs( + context: CausalProjectorContext, + projection: ModeledProjection, +) -> Result<(), HandlerError> { + projection.apply(BOROUGHS, &context).await } /// The projector of `zone_activities`, owned by this application. @@ -124,7 +147,9 @@ pub fn projection_service(read_models: SqliteRepository) -> Service { .modeled_projector(projector()) .handle(project) .modeled_projector(rooms_projector()) - .handle(project_rooms), + .handle(project_rooms) + .modeled_projector(boroughs_projector()) + .handle(project_boroughs), ) } @@ -151,6 +176,12 @@ fn grants() -> BTreeMap> { .or_default() .insert(ZoneActivities::schema().model_name.clone(), grant); } + for (role, grant) in borough_permissions().surface_grants() { + grants + .entry(role) + .or_default() + .insert(Boroughs::schema().model_name.clone(), grant); + } grants } @@ -159,7 +190,11 @@ fn full_surface() -> Surface { .expect("mobpit Surface should build") .with_leaderboards(leaderboard_surfaces()) .expect("mobpit boards should attach") - .with_projection_owners([projector().into(), rooms_projector().into()]) + .with_projection_owners([ + projector().into(), + rooms_projector().into(), + boroughs_projector().into(), + ]) .expect("mobpit projectors should bind") } @@ -227,7 +262,12 @@ pub fn build_engine( .client_application_surface(APPLICATION, [ROLE], [ROLE]) .model::(permissions()) .model::(room_permissions()) - .client_projection_owners([projector().into(), rooms_projector().into()]) + .model::(borough_permissions()) + .client_projection_owners([ + projector().into(), + rooms_projector().into(), + boroughs_projector().into(), + ]) .identity(identity.config()) .graphiql(false) .change_stream(repo.read_model_changes()) diff --git a/examples/game/server/sim/src/arena.rs b/examples/game/server/sim/src/arena.rs index aafd269a5..9e48505fc 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,22 +131,21 @@ 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))) } /// One command frame of stick, dash and pillars, without the walls and -/// unsnapped: the part of [`step`] Mob Pit shares (its rooms have doorways -/// in the walls, see `mobpit::pit_step`). +/// unsnapped. pub fn advance(s: MoveState, input: MoveInput) -> MoveState { + advance_among(s, input, &PILLARS) +} + +/// [`advance`] among round obstacles `(x, y, radius)`: the part Mob Pit +/// shares (its rooms have seeded obstacles and doorways in the walls, see +/// `mobpit::pit_step`). +pub fn advance_among(s: MoveState, input: MoveInput, obstacles: &[(f32, f32, f32)]) -> MoveState { let mut s = s; // Stick direction, length clamped to 1. let mut dx = f32::from(input.mx) / 127.0; @@ -199,7 +198,7 @@ pub fn advance(s: MoveState, input: MoveInput) -> MoveState { s.x += s.vx * DT; s.y += s.vy * DT; - for (px, py, pr) in PILLARS { + for &(px, py, pr) in obstacles { let ox = s.x - px; let oy = s.y - py; let d2 = ox * ox + oy * oy; diff --git a/examples/game/server/sim/src/grid.rs b/examples/game/server/sim/src/grid.rs index eeea74a72..ad814c309 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 0509a7746..6be307ca7 100644 --- a/examples/game/server/sim/src/lib.rs +++ b/examples/game/server/sim/src/lib.rs @@ -3,22 +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::()`): +//! 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::()`). //! -//! | step | kind | used by | -//! |---|---|---| -//! | [`arena::ArenaStep`] | `player` | Arena, 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 ([`mobpit::pit_world`]). //! //! Nothing here may depend on server-only crates (Tokio, axum, SQLite): //! the crate must compile for `wasm32-unknown-unknown`. @@ -26,4 +19,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 a1fa45964..1ebefe47e 100644 --- a/examples/game/server/sim/src/mobpit.rs +++ b/examples/game/server/sim/src/mobpit.rs @@ -19,14 +19,20 @@ //! the Endless Grid's seeded hashing and biomes ([`crate::grid`]); the page //! runs the same code compiled to WebAssembly (minimap colours, floor). +use std::cell::RefCell; + 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 an attack -/// bit 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)] @@ -34,9 +40,22 @@ pub struct PitInput { /// Stick y. #[range(min = -127, max = 127)] pub my: i8, - /// [`PitInput::DASH`] | [`PitInput::ATTACK`]. - #[buttons(dash, attack)] + /// [`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 { @@ -53,13 +72,134 @@ impl PitInput { pub fn attack(self) -> bool { self.buttons & Self::ATTACK != 0 } + + /// Jump held. + 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²). +pub const GRAVITY: f32 = 24.0; +/// Take-off speed of a jump (m/s): about 1.5 m up, 0.7 s in the air. +pub const JUMP_SPEED: f32 = 8.5; +/// Wire type of `z` (height above the floor, m). +pub const HEIGHT: FieldType = FieldType::quant_centered(4.0, 14); + +/// Mob Pit's predicted state: the Arena capsule on the floor plus a +/// vertical axis (`z` above the floor, `vz`). Every field is replicated +/// under the same name, so clients rebase on the authoritative copy. +#[derive(Debug, Clone, Copy, Default, PartialEq, GameRecord)] +pub struct PitState { + /// Position x. + pub x: f32, + /// Position y. + pub y: f32, + /// Velocity x. + pub vx: f32, + /// Velocity y. + pub vy: f32, + /// Remaining dash frames. + pub dash: u8, + /// Remaining dash cooldown frames. + pub cd: u8, + /// Height above the floor (m); 0 on the ground. + pub z: f32, + /// Vertical velocity (m/s). + pub vz: f32, +} + +impl PitState { + /// The floor part (the Arena's state). + pub fn planar(self) -> MoveState { + MoveState { + x: self.x, + y: self.y, + vx: self.vx, + vy: self.vy, + dash: self.dash, + cd: self.cd, + } + } + + /// `self` with its floor part replaced. + pub fn with_planar(self, m: MoveState) -> Self { + Self { + x: m.x, + y: m.y, + vx: m.vx, + vy: m.vy, + dash: m.dash, + cd: m.cd, + ..self + } + } + + /// Standing on the floor. + pub fn grounded(self) -> bool { + self.z <= 0.0 + } +} + +/// `(seed, x, y)` → that room's obstacles as `(x, y, radius)`. +type ObstacleCache = Option<((u32, i32, i32), Vec<(f32, f32, f32)>)>; + +thread_local! { + /// Obstacles of the last room stepped in (a player stays in a room for + /// many frames). + static OBSTACLES: RefCell = const { RefCell::new(None) }; } /// The own capsule's predicted step: the Arena's movement of -/// [`PitInput::movement`] inside walls with doorway passages. +/// [`PitInput::movement`] among the room's seeded obstacles, inside walls +/// with doorway passages. The context names the room, so the obstacles are +/// generated inside the step (the browser needs no copy of the room). #[predict(PitStep, kind = "player")] -pub fn pit_step(state: &MoveState, input: &PitInput, _context: &(), _dt: f32) -> MoveState { - step(*state, *input) +pub fn pit_step( + state: &PitState, + input: &PitInput, + context: &PitRoomContext, + _dt: f32, +) -> PitState { + let key = (context.seed, context.x, context.y); + OBSTACLES.with(|cache| { + let mut cache = cache.borrow_mut(); + if cache.as_ref().map(|(k, _)| *k) != Some(key) { + let circles = room_obstacles(context.seed, (context.x, context.y)) + .1 + .iter() + .map(|o| (o.x, o.y, o.r)) + .collect(); + *cache = Some((key, circles)); + } + let obstacles = &cache.as_ref().expect("filled above").1; + step_among(*state, *input, obstacles) + }) } /// Room edge length (m): rooms tile the world, sharing walls. @@ -74,20 +214,44 @@ const SLACK: f32 = 0.01; /// Wire type of player `x`/`y`: the room plus its passages, ≈ 0.17 mm steps. pub const POS: FieldType = FieldType::quant_centered(22.0, 18); -/// One command frame: [`arena::advance`] (stick, dash, pillars), then the -/// walls with their doorway passages, snapped to the wire precision. -pub fn step(s: MoveState, input: PitInput) -> MoveState { - let next = arena::advance(s, input.movement()); - canonicalize(walls(s, next)) +/// One command frame in a room with the classic pillars (the entrance). +pub fn step(s: PitState, input: PitInput) -> PitState { + step_among(s, input, &arena::PILLARS) +} + +/// One command frame: [`arena::advance_among`] (stick, dash, the room's +/// obstacles), then the walls with their doorway passages, then the jump +/// (take off from the floor, gravity, land), snapped to the wire precision. +pub fn step_among(s: PitState, input: PitInput, obstacles: &[(f32, f32, f32)]) -> PitState { + let planar = s.planar(); + let next = walls( + planar, + arena::advance_among(planar, input.movement(), obstacles), + ); + let (mut z, mut vz) = (s.z, s.vz); + if input.jump() && s.grounded() { + vz = JUMP_SPEED; + } + if z > 0.0 || vz > 0.0 { + z += vz * arena::DT; + vz -= GRAVITY * arena::DT; + if z <= 0.0 { + z = 0.0; + vz = 0.0; + } + } + canonicalize(PitState { z, vz, ..s }.with_planar(next)) } /// Snap to the replicated precision of a Mob Pit player. -pub fn canonicalize(s: MoveState) -> MoveState { - MoveState { +pub fn canonicalize(s: PitState) -> PitState { + PitState { x: arena::snap(POS, s.x), y: arena::snap(POS, s.y), vx: arena::snap(arena::VEL, s.vx), vy: arena::snap(arena::VEL, s.vy), + z: arena::snap(HEIGHT, s.z).max(0.0), + vz: arena::snap(arena::VEL, s.vz), ..s } } @@ -134,8 +298,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`). @@ -169,6 +344,162 @@ const SLOTS: [(f32, f32); 12] = [ /// Hash salt of the room contents. const SALT_ROOM: u32 = 900; +/// A round obstacle of a room (room-local): a column, a boulder, a tree. +#[derive(Debug, Clone, Copy, PartialEq, GameRecord)] +pub struct Obstacle { + /// Centre x (m, room-local). + pub x: f32, + /// Centre y. + pub y: f32, + /// Radius. + pub r: f32, + /// What it looks like: 0 column · 1 boulder · 2 tree · 3 crystal. + pub look: u8, +} + +/// Room patterns, by index ([`PitRoomLayout::pattern`]). +pub const PATTERNS: [&str; 6] = ["pillars", "colonnade", "ring", "rubble", "hall", "grove"]; +/// Most obstacles a room has. +pub const MAX_OBSTACLES: usize = 24; +/// Hash salt of the room obstacles. +const SALT_OBSTACLE: u32 = 902; +/// Half width of the clear corridor along each door axis near the walls. +const CORRIDOR: f32 = 3.2; +/// Free gap kept between obstacles, and between an obstacle and a wall or +/// a kept spot (a player's diameter plus slack): every room stays one +/// connected floor. +const GAP: f32 = 1.3; + +/// Whether `(x, y, r)` keeps clear of the door corridors, the walls and the +/// `keep` spots (`(x, y, clearance)`) and of `placed`. +fn fits(x: f32, y: f32, r: f32, keep: &[(f32, f32, f32)], placed: &[Obstacle]) -> bool { + let wall = arena::HALF - r - GAP; + if x.abs() > wall || y.abs() > wall { + return false; + } + // Door corridors: from each doorway 8 m into the room. + let inner = arena::HALF - 8.0; + if (x.abs() < CORRIDOR + r && y.abs() > inner - r) + || (y.abs() < CORRIDOR + r && x.abs() > inner - r) + { + return false; + } + keep.iter().all(|&(kx, ky, c)| { + let (dx, dy) = (x - kx, y - ky); + dx * dx + dy * dy >= (r + c) * (r + c) + }) && placed.iter().all(|o| { + let (dx, dy) = (x - o.x, y - o.y); + let min = r + o.r + GAP; + dx * dx + dy * dy >= min * min + }) +} + +/// Unit vectors at `(k + 0.5) / 8 · τ`, as literals (no trig: native and +/// WebAssembly agree bit for bit). +const RING8: [(f32, f32); 8] = [ + (0.9238795, 0.38268343), + (0.38268343, 0.9238795), + (-0.38268343, 0.9238795), + (-0.9238795, 0.38268343), + (-0.9238795, -0.38268343), + (-0.38268343, -0.9238795), + (0.38268343, -0.9238795), + (0.9238795, -0.38268343), +]; + +/// Spots every room keeps clear: the spawn points and the boss's home. +const KEEP: [(f32, f32, f32); 7] = [ + (-4.5, -4.5, 1.6), + (4.5, -4.5, 1.6), + (4.5, 4.5, 1.6), + (-4.5, 4.5, 1.6), + (0.0, -5.5, 1.6), + (0.0, 5.5, 1.6), + (BOSS_HOME.0, BOSS_HOME.1, 2.6), +]; + +/// The obstacles of room `room` and its pattern ([`PATTERNS`]): the +/// entrance has the classic pillars; every other room one seeded pattern, +/// each obstacle kept clear of the door corridors, the walls, the spawn +/// points, the mob homes and the other obstacles (by at least a player's +/// width), so the floor stays one connected place. Pure and deterministic. +pub fn room_obstacles(seed: u32, room: (i32, i32)) -> (u8, Vec) { + let classic = || { + arena::PILLARS + .iter() + .map(|&(x, y, r)| Obstacle { x, y, r, look: 0 }) + .collect::>() + }; + if room == (0, 0) { + return (0, classic()); + } + let h = grid::mix(seed, SALT_OBSTACLE, room.0, room.1); + let pattern = (h % PATTERNS.len() as u32) as u8; + if pattern == 0 { + return (0, classic()); + } + let mut keep: Vec<(f32, f32, f32)> = KEEP.to_vec(); + for m in mob_slots(seed, room) { + keep.push((m.x, m.y, 0.8 + GAP)); + } + let mut placed: Vec = Vec::new(); + let place = |x: f32, y: f32, r: f32, look: u8, placed: &mut Vec| { + if placed.len() < MAX_OBSTACLES && fits(x, y, r, &keep, placed) { + placed.push(Obstacle { x, y, r, look }); + } + }; + let roll = |i: u32| grid::unit(grid::hash32(h ^ i.wrapping_mul(0x9e37_79b9))); + match pattern { + // Two rows of columns along one axis. + 1 => { + let along_x = h & 0x100 != 0; + for row in [-6.5f32, 6.5] { + let mut t = -15.0f32; + while t <= 15.0 { + let (x, y) = if along_x { (t, row) } else { (row, t) }; + place(x, y, 0.8, 0, &mut placed); + t += 3.75; + } + } + } + // A ring of columns around a central plinth. + 2 => { + place(0.0, 0.0, 2.0, 0, &mut placed); + let radius = 8.5 + 2.0 * roll(1); + for (ux, uy) in RING8 { + place(ux * radius, uy * radius, 1.0, 0, &mut placed); + } + } + // Scattered boulders. + 3 => { + for i in 0..40u32 { + let x = -17.0 + 34.0 * roll(2 * i + 2); + let y = -17.0 + 34.0 * roll(2 * i + 3); + let r = 0.6 + 1.2 * roll(100 + i); + place(x, y, r, 1, &mut placed); + } + } + // A rounded hall: boulders in the corners, four columns. + 4 => { + for (sx, sy) in [(1.0f32, 1.0f32), (-1.0, 1.0), (-1.0, -1.0), (1.0, -1.0)] { + place(sx * 15.0, sy * 15.0, 3.5, 1, &mut placed); + place(sx * 6.5, sy * 6.5, 1.0, 0, &mut placed); + } + place(0.0, 0.0, 1.5, 3, &mut placed); + } + // A grove of trees. + _ => { + for i in 0..60u32 { + let x = -17.5 + 35.0 * roll(2 * i + 7); + let y = -17.5 + 35.0 * roll(2 * i + 8); + let r = 0.45 + 0.4 * roll(200 + i); + place(x, y, r, 2, &mut placed); + } + } + } + (pattern, placed) +} + /// Which room ([`pit_room`]'s input). #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, GameRecord)] pub struct PitRoomContext { @@ -198,8 +529,102 @@ 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, + /// Index into [`PATTERNS`]: how the room is laid out. + pub pattern: u8, + /// The room's obstacles (collision in the step, drawn by the pages). + pub obstacles: Vec, +} + +/// 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, @@ -211,7 +636,36 @@ pub fn pit_room(room: &PitRoomContext) -> PitRoomLayout { let biome = grid::biome(room.seed, at, (grid::CELL / 2.0, grid::CELL / 2.0)); let distance = room.x.unsigned_abs().saturating_add(room.y.unsigned_abs()); let danger = (1 + distance / 3).min(3) as u8; - let h = grid::mix(room.seed, SALT_ROOM, room.x, room.y); + let mut mobs = mob_slots(room.seed, at); + 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, + }); + } + let (pattern, obstacles) = room_obstacles(room.seed, at); + PitRoomLayout { + biome, + danger, + mobs, + boss, + hood_x: hood.0, + hood_y: hood.1, + boss_distance: boss_distance(room.seed, at), + pattern, + obstacles, + } +} + +/// The 6–12 ordinary mob slots of a room: two rings between the pillars, +/// tiers weighted by the room's danger. +fn mob_slots(seed: u32, at: (i32, i32)) -> Vec { + let distance = at.0.unsigned_abs().saturating_add(at.1.unsigned_abs()); + let danger = (1 + distance / 3).min(3) as u8; + let h = grid::mix(seed, SALT_ROOM, at.0, at.1); let count = 6 + (h % 7) as usize; let mut mobs = Vec::with_capacity(count); for i in 0..count { @@ -231,11 +685,7 @@ pub fn pit_room(room: &PitRoomContext) -> PitRoomLayout { y: ring * dy, }); } - PitRoomLayout { - biome, - danger, - mobs, - } + mobs } /// The rooms' world, for the page (generated `PIT_WORLD`). @@ -245,6 +695,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`. @@ -257,6 +713,11 @@ pub struct PitWorld { pub max_room: i32, /// Biome names by index. pub biomes: Vec, + /// Proximity wake: a doorway neighbour wakes within this distance of + /// its doorway ([`WAKE_REACH`], m). + pub wake_reach: f32, + /// … and is released beyond this one ([`WAKE_RELEASE`], m). + pub wake_release: f32, } /// The `PIT_WORLD` constant. @@ -264,12 +725,17 @@ 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, hysteresis: HYSTERESIS, max_room: MAX_ROOM, biomes: grid::BIOMES.map(String::from).to_vec(), + wake_reach: WAKE_REACH, + wake_release: WAKE_RELEASE, } } @@ -309,8 +775,8 @@ pub fn crossing(x: f32, y: f32) -> Option<(i32, i32)> { /// The same player in the frame of the neighbour at room offset `step`: /// the same world point and velocity (world = room × [`ROOM_SIZE`] + local). -pub fn into_neighbour(step: (i32, i32), s: MoveState) -> MoveState { - canonicalize(MoveState { +pub fn into_neighbour(step: (i32, i32), s: PitState) -> PitState { + canonicalize(PitState { x: s.x - step.0 as f32 * ROOM_SIZE, y: s.y - step.1 as f32 * ROOM_SIZE, ..s @@ -318,7 +784,7 @@ pub fn into_neighbour(step: (i32, i32), s: MoveState) -> MoveState { } /// Whether `s` is a position a player can be in (the room or a passage). -pub fn admissible(s: &MoveState) -> bool { +pub fn admissible(s: &PitState) -> bool { let lim = arena::HALF - arena::PLAYER_RADIUS + SLACK; let gap = DOOR_HALF - arena::PLAYER_RADIUS + SLACK; let far = arena::HALF + PASSAGE + SLACK; @@ -327,13 +793,167 @@ pub fn admissible(s: &MoveState) -> bool { && s.y.is_finite() && s.vx.is_finite() && s.vy.is_finite() + && s.z.is_finite() + && s.vz.is_finite() + && (0.0..=4.0).contains(&s.z) && ((ax <= lim && ay <= lim) || (ay <= gap && ax <= far) || (ax <= gap && ay <= far)) } +// ------------------------------------------------------------ proximity wake + +/// A doorway neighbour wakes (its watcher becomes *near*) when the player +/// is this close to the doorway leading to it (m, room-local, measured to +/// the middle of the doorway on the shared wall). Below half the room +/// (20 m) so that at most two doorways — the two of a corner — are ever in +/// reach, and nothing is from the middle of the room. +pub const WAKE_REACH: f32 = 11.0; +/// A near neighbour goes back to a passive watcher only beyond this +/// distance from its doorway ([`WAKE_REACH`] + 8 m hysteresis). Below 20 m, +/// so the middle of the room releases every neighbour. +pub const WAKE_RELEASE: f32 = WAKE_REACH + 8.0; +/// At most this many neighbours near at once (two doorways of a corner). +pub const WAKE_MAX: usize = 2; +/// An observer ticket grants `wake` only for rooms at most this many +/// doorways from the room the principal was last seen in (one doorway of +/// slack: a watcher for the next room's neighbours is asked for right after +/// a doorway, before the presence record follows). +pub const WAKE_DOORS: u32 = 2; + +/// The doorway neighbours as [`Reach::near`] bits and room offsets: east +/// (+x), west, north (+y), south. Corner (diagonal) rooms have no doorway +/// to this one and never wake. +pub const DOORWAYS: [(u8, (i32, i32)); 4] = [(1, (1, 0)), (2, (-1, 0)), (4, (0, 1)), (8, (0, -1))]; + +/// Where the player stands (room-local) and which doorway neighbours were +/// near ([`rooms_in_reach`]'s input). +#[derive(Debug, Clone, Copy, Default, PartialEq, GameRecord)] +pub struct ReachContext { + /// Room-local x (m). + pub x: f32, + /// Room-local y (m). + pub y: f32, + /// [`DOORWAYS`] bits near so far (the hysteresis). + pub near: u8, +} + +/// The doorway neighbours near now. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, GameRecord)] +pub struct Reach { + /// [`DOORWAYS`] bits. + pub near: u8, +} + +/// Proximity wake: which of the four doorway neighbours a player at +/// room-local `(x, y)` keeps awake. A neighbour becomes near within +/// [`WAKE_REACH`] of its doorway and stays near until beyond +/// [`WAKE_RELEASE`]; at most [`WAKE_MAX`] (the nearest). Pure, so both +/// pages run this code as WebAssembly. +#[game_pure(RoomsInReachFn)] +pub fn rooms_in_reach(at: &ReachContext) -> Reach { + let mut near: Vec<(f32, u8)> = DOORWAYS + .iter() + .filter_map(|&(bit, (dx, dy))| { + let door = (dx as f32 * arena::HALF, dy as f32 * arena::HALF); + let d = ((at.x - door.0).powi(2) + (at.y - door.1).powi(2)).sqrt(); + let limit = if at.near & bit != 0 { + WAKE_RELEASE + } else { + WAKE_REACH + }; + (d.is_finite() && d <= limit).then_some((d, bit)) + }) + .collect(); + near.sort_by(|a, b| a.0.total_cmp(&b.0)); + Reach { + near: near + .iter() + .take(WAKE_MAX) + .fold(0, |bits, &(_, bit)| bits | bit), + } +} + +/// The rooms of [`Reach::near`] bits around `room`. +pub fn reach_rooms(room: (i32, i32), near: u8) -> Vec<(i32, i32)> { + DOORWAYS + .iter() + .filter(|(bit, _)| near & bit != 0) + .map(|&(_, (dx, dy))| (room.0 + dx, room.1 + dy)) + .collect() +} + +/// May a principal last seen in room `from` be granted `wake` for room +/// `to` (an observer ticket)? Only other rooms within [`WAKE_DOORS`] +/// doorways. +pub fn wake_allowed(from: (i32, i32), to: (i32, i32)) -> bool { + let doors = from.0.abs_diff(to.0).saturating_add(from.1.abs_diff(to.1)); + (1..=WAKE_DOORS).contains(&doors) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn proximity_wake_reaches_doorways_only_two_at_most_with_hysteresis() { + let reach = |x: f32, y: f32, near: u8| rooms_in_reach(&ReachContext { x, y, near }).near; + // The middle of the room: nothing, and it releases everything. + assert_eq!(reach(0.0, 0.0, 0), 0); + assert_eq!(reach(0.0, 0.0, 15), 0); + // Walking east toward the doorway: wakes within WAKE_REACH. + assert_eq!(reach(20.0 - WAKE_REACH - 0.1, 0.0, 0), 0); + assert_eq!(reach(20.0 - WAKE_REACH + 0.1, 0.0, 0), 1); + assert_eq!(reach(19.0, 0.0, 0), 1); + // In the doorway passage (past the wall line), still east only. + assert_eq!(reach(21.5, 0.0, 0), 1); + // Walking back: stays near until beyond WAKE_RELEASE. + assert_eq!(reach(20.0 - WAKE_RELEASE + 0.1, 0.0, 1), 1); + assert_eq!(reach(20.0 - WAKE_RELEASE - 0.1, 0.0, 1), 0); + // A room corner: no doorway is in reach (diagonals never wake). + assert_eq!(reach(18.5, 18.5, 0), 0); + // Every spot of the room (and the hysteresis from any prior set): + // at most two, never two opposite ones. + let mut seen_two = false; + for prior in 0..16u8 { + for i in 0..=88 { + for j in 0..=88 { + let (x, y) = (-22.0 + i as f32 * 0.5, -22.0 + j as f32 * 0.5); + let near = reach(x, y, prior); + assert!( + near.count_ones() as usize <= WAKE_MAX, + "({x}, {y}) {prior:#b}" + ); + assert!( + near & 3 != 3 && near & 12 != 12, + "opposite doorways at ({x}, {y})" + ); + seen_two |= near.count_ones() == 2; + } + } + } + assert!(seen_two, "two doorways of a corner can both be near"); + // Walking a corner path east then north holds both briefly. + let mut near = 0; + for (x, y) in [(15.0, 0.0), (12.0, 6.0), (6.0, 11.0)] { + near = reach(x, y, near); + } + assert_eq!(near, 1 | 4); + assert_eq!(reach_rooms((3, -2), near), vec![(4, -2), (3, -1)]); + } + + #[test] + fn wake_is_granted_only_within_two_doorways() { + assert!(wake_allowed((0, 0), (1, 0))); + assert!(wake_allowed((0, 0), (2, 0)), "the next room's neighbour"); + assert!(wake_allowed((0, 0), (1, 1))); + assert!( + !wake_allowed((0, 0), (0, 0)), + "the own room needs no watcher" + ); + assert!(!wake_allowed((0, 0), (3, 0))); + assert!(!wake_allowed((0, 0), (2, 1))); + assert!(!wake_allowed((MAX_ROOM, 0), (-MAX_ROOM, 0))); + } + #[test] fn rooms_are_deterministic_and_vary() { let ctx = |x, y| PitRoomContext { @@ -352,6 +972,187 @@ 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); + } + + /// Free floor points reachable from a spawn point (0.5 m lattice). + fn reachable(obstacles: &[Obstacle]) -> Vec> { + let n = 79usize; // -19.5 ..= 19.5 + let at = |i: usize| -19.5 + i as f32 * 0.5; + let free = |x: f32, y: f32| { + let lim = arena::HALF - arena::PLAYER_RADIUS; + x.abs() <= lim + && y.abs() <= lim + && obstacles.iter().all(|o| { + let (dx, dy) = (x - o.x, y - o.y); + dx * dx + dy * dy >= (o.r + arena::PLAYER_RADIUS).powi(2) + }) + }; + let mut seen = vec![vec![false; n]; n]; + let start = ((4.5 + 19.5) / 0.5) as usize; + let mut stack = vec![(start, start)]; + while let Some((i, j)) = stack.pop() { + if seen[i][j] || !free(at(i), at(j)) { + continue; + } + seen[i][j] = true; + if i > 0 { + stack.push((i - 1, j)); + } + if j > 0 { + stack.push((i, j - 1)); + } + if i + 1 < n { + stack.push((i + 1, j)); + } + if j + 1 < n { + stack.push((i, j + 1)); + } + } + seen + } + + #[test] + fn rooms_vary_and_every_room_stays_one_floor() { + let mut patterns = std::collections::BTreeSet::new(); + let cell = |v: f32| ((v + 19.5) / 0.5).round() as usize; + for y in -6..=6 { + for x in -6..=6 { + let layout = pit_room(&PitRoomContext { + seed: DEFAULT_SEED, + x, + y, + }); + patterns.insert(layout.pattern); + assert!(layout.obstacles.len() <= MAX_OBSTACLES); + let seen = reachable(&layout.obstacles); + // Every doorway, spawn point and mob home is reachable. + for (px, py) in [(18.5, 0.0), (-18.5, 0.0), (0.0, 18.5), (0.0, -18.5)] + .into_iter() + .chain(KEEP.iter().map(|k| (k.0, k.1))) + { + assert!( + seen[cell(px)][cell(py)], + "pit.{x}.{y} ({}): {px},{py} cut off", + PATTERNS[layout.pattern as usize] + ); + } + for m in &layout.mobs { + let near = [(0.0, 0.0), (1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] + .iter() + .any(|(dx, dy)| seen[cell(m.x + dx)][cell(m.y + dy)]); + assert!(near, "pit.{x}.{y}: mob home {},{} cut off", m.x, m.y); + } + } + } + assert!(patterns.len() >= 5, "{patterns:?}"); + assert_eq!( + room_obstacles(DEFAULT_SEED, (0, 0)).0, + 0, + "the entrance is classic" + ); + } + + #[test] + fn the_step_collides_with_the_rooms_obstacles() { + // A room with a ring: walk from the centre outwards along a column. + let (pattern, obstacles) = (1..40) + .map(|x| room_obstacles(DEFAULT_SEED, (x, 3))) + .find(|(p, o)| *p == 3 && !o.is_empty()) + .expect("a rubble room"); + assert_eq!(pattern, 3); + let o = obstacles[0]; + let circles: Vec<(f32, f32, f32)> = obstacles.iter().map(|o| (o.x, o.y, o.r)).collect(); + let mut s = canonicalize(PitState { + x: o.x - (o.r + 3.0), + y: o.y, + ..PitState::default() + }); + for _ in 0..90 { + s = step_among( + s, + PitInput { + mx: 127, + ..PitInput::default() + }, + &circles, + ); + let (dx, dy) = (s.x - o.x, s.y - o.y); + assert!( + dx * dx + dy * dy >= (o.r + arena::PLAYER_RADIUS - 1e-3).powi(2), + "{s:?}" + ); + } + } + + #[test] + fn a_jump_goes_up_and_lands() { + let jump = PitInput { + buttons: PitInput::JUMP, + ..PitInput::default() + }; + let mut s = canonicalize(PitState { + x: 4.5, + y: 4.5, + ..PitState::default() + }); + s = step(s, jump); + assert!(s.z > 0.2 && s.vz > 0.0, "{s:?}"); + let mut apex = 0.0f32; + let mut frames = 1; + while !s.grounded() { + // Holding jump in the air does nothing more. + s = step(s, jump); + apex = apex.max(s.z); + frames += 1; + assert!(frames < 60, "lands: {s:?}"); + } + assert!((1.2..1.8).contains(&apex), "apex {apex}"); + assert_eq!((s.z, s.vz), (0.0, 0.0)); + assert!((18..26).contains(&frames), "{frames} frames in the air"); + // Through a doorway mid-air, the height is carried. + let air = canonicalize(PitState { + x: 20.6, + y: 0.5, + z: 0.8, + vz: 2.0, + ..PitState::default() + }); + let n = into_neighbour((1, 0), air); + assert_eq!((n.z, n.vz), (air.z, air.vz)); + } + #[test] fn room_ids_and_doorways() { assert_eq!(room_zone((1, -2)), "pit.1.-2"); @@ -361,9 +1162,16 @@ mod tests { assert_eq!(parse_room("pit.2000000.0"), None); } - fn walk(mut s: MoveState, mx: i8, my: i8, frames: usize) -> MoveState { + 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 } @@ -373,10 +1181,10 @@ mod tests { let lim = arena::HALF - arena::PLAYER_RADIUS; // Along the east door axis (y = 0.5): through the gap past the wall // line, up to the passage's end. - let at = |x, y| MoveState { + let at = |x, y| PitState { x, y, - ..MoveState::default() + ..PitState::default() }; let s = walk(at(17.0, 0.5), 127, 0, 60); assert!((s.x - (arena::HALF + PASSAGE)).abs() < 1e-3, "{s:?}"); @@ -396,11 +1204,11 @@ mod tests { #[test] fn crossing_keeps_the_world_point() { - let s = canonicalize(MoveState { + let s = canonicalize(PitState { x: arena::HALF + HYSTERESIS + 0.1, y: 1.25, vx: 5.0, - ..MoveState::default() + ..PitState::default() }); let step = crossing(s.x, s.y).expect("past the line"); let n = into_neighbour(step, s); @@ -410,18 +1218,18 @@ mod tests { assert!(admissible(&n)); assert_eq!(crossing(n.x, n.y), None, "hysteresis: no bounce back"); // West / south mirror. - let w = into_neighbour((-1, 0), MoveState { x: -21.0, ..s }); + let w = into_neighbour((-1, 0), PitState { x: -21.0, ..s }); assert!((w.x - 19.0).abs() < 1e-3, "{w:?}"); let south = into_neighbour( (0, -1), - MoveState { + PitState { x: 0.0, y: -21.0, ..s }, ); assert!((south.y - 19.0).abs() < 1e-3, "{south:?}"); - assert!(!admissible(&MoveState { + assert!(!admissible(&PitState { x: 21.0, y: 5.0, ..s diff --git a/examples/game/server/sim/src/ripple.rs b/examples/game/server/sim/src/ripple.rs deleted file mode 100644 index 6b53e598d..000000000 --- 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/arena/mod.rs b/examples/game/server/src/arena/mod.rs deleted file mode 100644 index 8ffe5e1c6..000000000 --- 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/cluster.rs b/examples/game/server/src/cluster.rs deleted file mode 100644 index f9c6233fa..000000000 --- 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/common/dev.rs b/examples/game/server/src/common/dev.rs index 443d99355..56e8411d6 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 0d46c1b5e..475462c49 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 (`arena` → `/arena/`). - 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/freeze/content.rs b/examples/game/server/src/freeze/content.rs deleted file mode 100644 index 4b41a266d..000000000 --- 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 crate::arena::movement::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 a1b4b0559..000000000 --- a/examples/game/server/src/freeze/mod.rs +++ /dev/null @@ -1,609 +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 ([`crate::arena::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::arena::movement::{self, MoveInput, MoveState}; -use crate::arena::PlayerView; -use crate::common::{Demo, Guests}; -use content::{Hazard, Pack, ZoneKind}; - -/// 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()) -} - -/// 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(crate::arena::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") - } -} diff --git a/examples/game/server/src/freeze/world.rs b/examples/game/server/src/freeze/world.rs deleted file mode 100644 index e00898995..000000000 --- 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/src/gallery/mod.rs b/examples/game/server/src/gallery/mod.rs deleted file mode 100644 index 664d364c7..000000000 --- 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/src/grid/dev.rs b/examples/game/server/src/grid/dev.rs deleted file mode 100644 index 8dda2e934..000000000 --- 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 d778fd501..000000000 --- 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 fd138f036..962dc9904 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,13 +12,7 @@ //! //! See `examples/game/README.md`. -pub mod arena; -pub mod cluster; 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 b523c2a20..6c5ddcf22 100644 --- a/examples/game/server/src/main.rs +++ b/examples/game/server/src/main.rs @@ -1,14 +1,7 @@ //! `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; -use game_demos::gallery::GalleryDemo; -use game_demos::grid::GridDemo; use game_demos::mobpit::{self, MobPitArgs}; -use game_demos::ripple::RippleDemo; #[derive(Parser)] #[command( @@ -22,20 +15,10 @@ 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`). 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), - /// 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 /// streams (JSON; the WASM exact-match test replays them). #[command(hide = true)] @@ -45,12 +28,7 @@ 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, - DemoCommand::Grid(args) => run_cluster::(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/mod.rs b/examples/game/server/src/mobpit/mod.rs index 7e826d735..b11829954 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 3191a41a6..f10108c02 100644 --- a/examples/game/server/src/mobpit/routes.rs +++ b/examples/game/server/src/mobpit/routes.rs @@ -20,6 +20,22 @@ //! - `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 +//! 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; @@ -30,6 +46,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}; @@ -54,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)) @@ -67,7 +91,15 @@ 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)) - .with_state(Dev { world, key }) + .route("/dev/mobpit/tiers", get(tiers)) + .route("/dev/netsim", get(get_netsim).post(set_netsim)) + .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( @@ -98,6 +130,9 @@ async fn ticket( .duration_since(UNIX_EPOCH) .map_or(0, |d| d.as_secs()) + TICKET_TTL_SECS; + // Proximity wake: an observer may wake (and keep awake) only a room + // within two doorways of the room its principal was last seen in. + let wake = role == Role::Observer && may_wake(&dev, &principal, &request.zone).await; let ticket = dev.key.sign( &TicketClaims::new( principal.clone(), @@ -106,7 +141,8 @@ async fn ticket( guests::random_hex(24), role, ) - .with_name(name.clone()), + .with_name(name.clone()) + .with_wake(wake), ); Ok(Json(TicketResponse { ticket, @@ -118,6 +154,19 @@ async fn ticket( })) } +/// The wake grant of an observer ticket for `zone`: the principal's last +/// room (its presence) is at most [`WAKE_DOORS`](game_sim::mobpit::WAKE_DOORS) +/// doorways away. +async fn may_wake(dev: &Dev, principal: &str, zone: &str) -> bool { + let (Some(to), Some(from)) = ( + mobpit_rooms::parse_room(zone), + dev.world.last_room(principal).await, + ) else { + return false; + }; + mobpit_rooms::parse_room(&from).is_some_and(|from| game_sim::mobpit::wake_allowed(from, to)) +} + /// `POST /dev/mobpit/start` body. #[derive(Debug, serde::Deserialize)] #[serde(deny_unknown_fields)] @@ -198,3 +247,185 @@ 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)) +} + +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], + "event": state["event"].as_str().unwrap_or_default(), + "eventUntilMs": state["event_until_ms"].as_u64().unwrap_or(0), + })); + } + 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 }), + )) +} + +fn packs_json() -> 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 28a5d66c2..1f17da9ab 100644 --- a/examples/game/server/src/mobpit/store.rs +++ b/examples/game/server/src/mobpit/store.rs @@ -126,6 +126,98 @@ 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) + } + + /// 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 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 e2082e794..62bdae803 100644 --- a/examples/game/server/src/predict_trace.rs +++ b/examples/game/server/src/predict_trace.rs @@ -15,13 +15,8 @@ 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::arena; use game_sim::mobpit::PitStep; -use game_sim::ripple::HoodStep; use serde_json::{json, Value}; /// Deterministic xorshift32 (streams must be identical on every run). @@ -132,114 +127,94 @@ fn random(seed: u32, frames: usize, extra_bytes: usize) -> Vec> { out } -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| { + PitRoomContext { + seed: DEFAULT_SEED, + x, + y, + } + .encode_context() + }; let start = |x: f32, y: f32| { - state_bytes(&arena::canonicalize(MoveState { + state_bytes(&canonicalize(PitState { x, y, - ..MoveState::default() + ..PitState::default() })) }; - let extra = move |i: usize| vec![(i as u8).wrapping_mul(37); extra_bytes]; - vec![ + // The capsule script with a jump every 37 frames (the jump bit is 0x04). + let scripted_jumps: Vec> = scripted(&|_| Vec::new()) + .into_iter() + .enumerate() + .map(|(i, mut f)| { + if i % 37 < 3 { + f[2] |= 0x04; + } + f + }) + .collect(); + let scripted_jumps = pit_frames(scripted_jumps, 7); + let mut streams = vec![ Stream { - name: "scripted".into(), + name: "scripted + jumps".into(), start: start(-5.0, -5.0), - context: Vec::new(), - inputs: scripted(&extra), + context: context(0, 0), + inputs: scripted_jumps, }, Stream { name: "random".into(), start: start(8.0, 3.0), - context: Vec::new(), - inputs: random(0x1234_5678, 3000, extra_bytes), + context: context(0, 0), + inputs: pit_frames(random(0x1234_5678, 3000, 0), 1), }, Stream { name: "corner".into(), start: start(18.5, -18.5), - context: Vec::new(), - inputs: random(0x0bad_cafe, 600, extra_bytes), + context: context(0, 0), + inputs: pit_frames(random(0x0bad_cafe, 600, 0), 2), }, - ] -} - -/// 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()); + ]; + let mut patterns = std::collections::BTreeSet::new(); + for x in 1..60 { + let (pattern, _) = room_obstacles(DEFAULT_SEED, (x, 2)); + if pattern == 0 || !patterns.insert(pattern) { + continue; } - } - 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), + name: format!("pit.{x}.2 pattern {pattern}"), + start: start(4.5, 4.5), + context: context(x, 2), + inputs: pit_frames(random(0x51ed ^ x as u32, 1200, 0), x as u32), }); } streams @@ -256,8 +231,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 +242,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,56 +251,47 @@ fn content_cells() -> Vec<(i32, i32)> { cells } -/// `grid_cell` for every content cell, under two seeds. -fn grid_cell_args() -> Vec> { +/// `pit_room` for the content cells (as rooms), under two seeds. +fn pit_room_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()); + for seed in [game_sim::mobpit::DEFAULT_SEED, 0xdead_beef] { + for (x, y) in content_cells() { + args.push(game_sim::mobpit::PitRoomContext { seed, x, y }.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); +/// Proximity wake: positions over the room and its doorway passages (a +/// lattice plus seeded random points, edge values included), with every +/// prior near set. +fn rooms_in_reach_args() -> Vec> { let mut args = Vec::new(); - for (cx, cy) in content_cells() { - let mut points: Vec<(f32, f32)> = vec![ + let mut rng = 0x00c0_ffeeu32; + let mut next = || { + rng ^= rng << 13; + rng ^= rng >> 17; + rng ^= rng << 5; + rng + }; + for near in 0..16u8 { + for &(x, y) in &[ (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))); + (9.0, 0.0), + (-9.0, 0.0), + (0.0, 9.0), + (0.0, -9.0), + (1.0, 0.0), + (21.5, 0.0), + (18.0, 18.0), + (-6.0, 11.0), + ] { + args.push(game_sim::mobpit::ReachContext { x, y, near }.encode_context()); } - 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(); - for seed in [game_sim::mobpit::DEFAULT_SEED, 0xdead_beef] { - for (x, y) in content_cells() { - args.push(game_sim::mobpit::PitRoomContext { seed, x, y }.encode_context()); + for _ in 0..4 { + let x = (next() % 4400) as f32 / 100.0 - 22.0; + let y = (next() % 4400) as f32 / 100.0 - 22.0; + args.push(game_sim::mobpit::ReachContext { x, y, near }.encode_context()); } } args @@ -337,15 +302,11 @@ 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()), + pure_trace::(rooms_in_reach_args()), ], "steps": [ - trace::(capsule_streams(0)), - trace::(capsule_streams(0)), - trace::(capsule_streams(1)), - trace::(grid_streams()), + trace::(pit_streams()), ], }) } @@ -354,53 +315,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(), 1); for step in steps { for stream in step["streams"].as_array().unwrap() { assert_eq!( diff --git a/examples/game/server/src/ripple/mod.rs b/examples/game/server/src/ripple/mod.rs deleted file mode 100644 index 71c5ee80f..000000000 --- a/examples/game/server/src/ripple/mod.rs +++ /dev/null @@ -1,893 +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::arena::movement::{self, MoveState}; -use crate::cluster::{ClusterDemo, World}; -use crate::common::{Demo, Guests}; - -/// 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()) -} - -/// 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/arena_smoke.rs b/examples/game/server/tests/arena_smoke.rs deleted file mode 100644 index 316d725bd..000000000 --- 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 deleted file mode 100644 index fc341304e..000000000 --- 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::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}; -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(arena::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/examples/game/server/tests/gallery_smoke.rs b/examples/game/server/tests/gallery_smoke.rs deleted file mode 100644 index 5cec901be..000000000 --- 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); - } - } -} diff --git a/examples/game/server/tests/grid_smoke.rs b/examples/game/server/tests/grid_smoke.rs deleted file mode 100644 index 48c2d6379..000000000 --- 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/server/tests/mobpit_smoke.rs b/examples/game/server/tests/mobpit_smoke.rs index 4f24887ad..562ae003c 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; @@ -598,6 +602,7 @@ async fn presence_is_settled_in_presence_cells_from_the_rooms_lifecycle_facts() capabilities: 0, ticket, reconnect_token: String::new(), + near: false, }); ws.send(Message::Binary(hello.encode().into())) .await diff --git a/examples/game/server/tests/ripple_smoke.rs b/examples/game/server/tests/ripple_smoke.rs deleted file mode 100644 index 4db41702c..000000000 --- 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 6070a9d98..000000000 --- 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; - } -} diff --git a/examples/game/server/tiers/Cargo.toml b/examples/game/server/tiers/Cargo.toml new file mode 100644 index 000000000..33e0c3138 --- /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/events.rs b/examples/game/server/tiers/src/events.rs new file mode 100644 index 000000000..882cdc10c --- /dev/null +++ b/examples/game/server/tiers/src/events.rs @@ -0,0 +1,84 @@ +//! The `boroughs` read model: one row per borough — bosses down, weather, +//! tier and the **world event** running in it — the [`BoroughState`] every +//! borough domain event carries (a hood seen, an event started or ended). +//! `mobpit_service` projects it from the bus and serves it over GraphQL +//! `@live` (the SvelteKit page's active events). + +use distributed::graphql::SurfaceProjector; +use distributed::projection::lower::{EventualOnly, ProjectionDescriptor}; +use distributed::{ + mutation_file, LocalProjectionMountsBuilder, Mutation, ReadModel, ReadModelCatalog, + RelationalReadModel, +}; +use serde::{Deserialize, Serialize}; + +#[allow(unused_imports)] +use crate::BoroughState; +use crate::{ + BoroughChangedDomainEvent, BoroughEventEndedDomainEvent, BoroughEventStartedDomainEvent, +}; + +/// Projection name. +pub const PROJECTOR: &str = "project_boroughs"; +/// Projection epoch: bump to rebuild the table from the bus log. +pub const EPOCH: &str = "mobpit-boroughs-v1"; + +/// One borough as its cell last published it. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ReadModel)] +#[table("boroughs")] +pub struct Boroughs { + /// `bx.by`. + #[id("borough")] + pub borough: String, + /// Bosses down among its 9 hoods. + pub bosses_down: i64, + /// Index into `WEATHER`. + pub weather: i64, + /// Sum of its hoods' tiers. + pub tier: i64, + /// The world event running (`invasion`, `frenzy`, `blessing`; empty when none). + pub event: String, + /// When it ends (Unix ms, 0 when none). + pub event_until_ms: i64, +} + +/// Upsert one `boroughs` row from the domain event's state body. +#[allow(non_snake_case)] +pub fn SaveBorough() -> Mutation<()> { + mutation_file!("src/mutations/save_borough.mutation.graphql") +} + +distributed::projection! { + pub const BOROUGHS: ProjectionDescriptor = { + name: "project_boroughs", + version: 1, + epoch: "mobpit-boroughs-v1", + model: Boroughs, + on { + events: [ + BoroughChangedDomainEvent, + BoroughEventStartedDomainEvent, + BoroughEventEndedDomainEvent + ], + mutation: SaveBorough, + input: { borough: body }, + }, + }; +} + +/// Physical read-model catalog (`boroughs`) under `application`. +pub fn read_model_catalog(application: &str) -> ReadModelCatalog { + ReadModelCatalog::new(application).table_schema(Boroughs::schema().clone()) +} + +/// The modeled projector mount for [`BOROUGHS`], owned by `application`. +pub fn projector(application: &str) -> SurfaceProjector { + LocalProjectionMountsBuilder::new(application, "ordered-domain-events") + .expect("projection source") + .eventual_model::(PROJECTOR, BOROUGHS, EPOCH) + .expect("boroughs mount") + .build() + .expect("projection catalog") + .projector(PROJECTOR) + .expect("boroughs projector") +} diff --git a/examples/game/server/tiers/src/lib.rs b/examples/game/server/tiers/src/lib.rs new file mode 100644 index 000000000..33b10d796 --- /dev/null +++ b/examples/game/server/tiers/src/lib.rs @@ -0,0 +1,964 @@ +//! **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. + +pub mod events; + +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, and its world event cycle. +#[derive(Clone, Debug, Default, Serialize, Deserialize, distributed::Snapshot)] +pub struct Borough { + entity: Entity, + /// Hood shard → what was heard last. + pub hoods: BTreeMap, + /// The world event running now (one of [`EVENTS`]; empty between them). + pub event: String, + /// When it ends (cell clock, ms). + pub event_until_ms: u64, + /// Events run so far (picks the next kind). + pub events_run: u32, + /// The event cycle is scheduled. + pub armed: bool, + /// The last time a room of the borough woke (cell clock, ms). + pub last_awake_ms: u64, +} + +/// World events a borough runs, in turn: `invasion` (mobs aggro from twice +/// as far and come back at once), `frenzy` (mobs 40 % faster), `blessing` +/// (players heal fast). Effects only — rewards stay the cells' business. +pub const EVENTS: [&str; 3] = ["invasion", "frenzy", "blessing"]; +/// The first event starts this long after the cycle is armed (ms). +pub const EVENT_FIRST_MS: u64 = 10_000; +/// An event lasts this long (ms). +pub const EVENT_LENGTH_MS: u64 = 30_000; +/// The next event starts this long after the last ended (ms). +pub const EVENT_GAP_MS: u64 = 30_000; +/// With no room of the borough awake for this long the cycle stops (ms). +pub const EVENT_IDLE_MS: u64 = 300_000; +/// Command: a room of the borough woke (zone fact; arms the cycle). +pub const ROOM_AWAKE: &str = "borough.room_awake"; +/// Command (schedule only): the next event starts. +pub const EVENT_START: &str = "borough.event_start"; +/// Command (schedule only): the event ends. +pub const EVENT_END: &str = "borough.event_end"; +/// Schedule name of the cycle. +const EVENT_ALARM: &str = "event"; + +/// 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, + /// The world event running (one of [`EVENTS`], empty when none). + pub event: String, + /// When it ends (ms, 0 when none). + pub event_until_ms: u64, +} + +impl From<&Borough> for BoroughState { + fn from(b: &Borough) -> Self { + let bosses_down = b.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(), + event: b.event.clone(), + event_until_ms: b.event_until_ms, + } + } +} + +#[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); + } + + #[event("borough.room_awake", version = 1)] + fn room_awake(&mut self, borough: String, at_ms: u64, arm: bool) { + if self.entity.id().is_empty() { + self.entity.set_id(borough); + } + self.last_awake_ms = at_ms; + self.armed |= arm; + } + + #[event("borough.event_started", version = 1, domain)] + fn event_started(&mut self, kind: String, until_ms: u64) { + self.event = kind; + self.event_until_ms = until_ms; + self.events_run += 1; + } + + #[event("borough.event_ended", version = 1, domain)] + fn event_ended(&mut self, rearmed: bool) { + self.event.clear(); + self.event_until_ms = 0; + self.armed = rearmed; + } +} + +impl Borough { + /// The outward state. + pub fn state(&self) -> BoroughState { + BoroughState::from(self) + } + + /// Hoods whose boss is down (`hx.hy`). + pub fn hoods_down(&self) -> Vec { + self.hoods + .iter() + .filter(|(_, h)| h.boss_down) + .map(|(k, _)| k.clone()) + .collect() + } +} + +/// [`ROOM_AWAKE`]: a room of the borough woke (zone fact of that room). +#[derive( + Debug, Clone, Serialize, Deserialize, distributed::CommandInput, distributed_game::FactCommand, +)] +#[fact(command = ROOM_AWAKE)] +pub struct RoomAwakeInput { + /// `bx.by`: the borough's shard (the fact route's key). + pub borough: String, + /// The room (`pit..`). + pub room: String, +} + +/// [`EVENT_START`] / [`EVENT_END`] input (the borough's own schedule). +#[derive(Debug, Clone, Serialize, Deserialize, distributed::CommandInput)] +pub struct EventInput { + /// `bx.by`. + pub borough: String, +} + +fn event_schedule(borough: &str, at_ms: u64, command: &str) -> Result { + Schedule::at( + EVENT_ALARM, + SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms), + command, + &EventInput { + borough: borough.to_string(), + }, + ) + .map_err(rejected) +} + +/// Only a room of the borough, as itself. +fn is_room_of_borough(ctx: &CausalCommandContext<'_, Borough>) -> bool { + ctx.session() + .user_id() + .is_some_and(|p| p.starts_with("zone:pit.")) +} + +async fn handle_room_awake( + ctx: &CausalCommandContext<'_, Borough>, + input: RoomAwakeInput, +) -> 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 it woke")); + } + if pair_shard(borough_of(hood_of(room))) != input.borough { + return Err(rejected("the room is not in this borough")); + } + let now = now_ms(ctx)?; + let repo = ctx.repo(); + let mut borough = match repo.get(&input.borough).await? { + Some(b) => b, + None => repo.create(), + }; + let arm = !borough.armed; + borough + .room_awake(input.borough.clone(), now, arm) + .map_err(rejected)?; + if arm { + // The cycle: the first event soon, then the schedule keeps it going. + ctx.schedule(event_schedule( + &input.borough, + now + EVENT_FIRST_MS, + EVENT_START, + )?)?; + } + repo.commit(borough)? + .succeeded(TierReceipt { changed: arm }) +} + +async fn handle_event_start( + ctx: &CausalCommandContext<'_, Borough>, + input: EventInput, +) -> Result>, HandlerError> { + let now = now_ms(ctx)?; + let repo = ctx.repo(); + let Some(mut borough) = repo.get(&input.borough).await? else { + return Err(rejected("no borough")); + }; + if !borough.event.is_empty() { + return repo + .commit(borough)? + .succeeded(TierReceipt { changed: false }); + } + let kind = EVENTS[borough.events_run as usize % EVENTS.len()].to_string(); + let until = now + EVENT_LENGTH_MS; + borough.event_started(kind, until).map_err(rejected)?; + ctx.schedule(event_schedule(&input.borough, until, EVENT_END)?)?; + repo.publish_events() + .commit(borough)? + .succeeded(TierReceipt { changed: true }) +} + +async fn handle_event_end( + ctx: &CausalCommandContext<'_, Borough>, + input: EventInput, +) -> Result>, HandlerError> { + let now = now_ms(ctx)?; + let repo = ctx.repo(); + let Some(mut borough) = repo.get(&input.borough).await? else { + return Err(rejected("no borough")); + }; + // Keep the cycle going while rooms of the borough are about. + let rearm = now.saturating_sub(borough.last_awake_ms) < EVENT_IDLE_MS; + borough.event_ended(rearm).map_err(rejected)?; + if rearm { + ctx.schedule(event_schedule( + &input.borough, + now + EVENT_GAP_MS, + EVENT_START, + )?)?; + } + repo.publish_events() + .commit(borough)? + .succeeded(TierReceipt { changed: true }) +} + +/// [`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!( + RoomAwake, + Borough, + ROOM_AWAKE, + RoomAwakeInput, + TierReceipt, + is_room_of_borough, + handle_room_awake, + Capability::Scheduling, + Capability::Clock +); +tier_command!( + EventStart, + Borough, + EVENT_START, + EventInput, + TierReceipt, + schedule_guard::, + handle_event_start, + Capability::Scheduling, + Capability::Clock +); +tier_command!( + EventEnd, + Borough, + EVENT_END, + EventInput, + TierReceipt, + schedule_guard::, + handle_event_end, + Capability::Scheduling, + Capability::Clock +); +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(RoomAwake) + .mount(EventStart) + .mount(EventEnd) +} + +/// 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, + event: String::new(), + event_until_ms: 0, + }, + 3, + ); + assert_eq!((city.city.as_str(), city.bosses_down), (CITY_SHARD, 9)); + } +} diff --git a/examples/game/server/tiers/src/mutations/save_borough.mutation.graphql b/examples/game/server/tiers/src/mutations/save_borough.mutation.graphql new file mode 100644 index 000000000..49b04cd2c --- /dev/null +++ b/examples/game/server/tiers/src/mutations/save_borough.mutation.graphql @@ -0,0 +1,5 @@ +# Syntax-only read-model mutation → MutationProgram IR (not a public field). +# The row is the borough's state carried by its domain events. +mutation SaveBorough { + upsert_boroughs(object: $input.borough) +} diff --git a/examples/game/server/tiers/tests/tiers.rs b/examples/game/server/tiers/tests/tiers.rs new file mode 100644 index 000000000..d97e8dcc6 --- /dev/null +++ b/examples/game/server/tiers/tests/tiers.rs @@ -0,0 +1,279 @@ +//! 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 + ); +} + +#[tokio::test] +async fn a_borough_runs_world_events_on_its_own_schedule() { + let clock = ManualClock::new(SystemTime::UNIX_EPOCH + Duration::from_millis(T0)); + let borough = mount_borough(cell::(&clock, "Borough", "0.0")); + let awake = |room: &str| json!({ "borough": "0.0", "room": room }); + // Only a room of the borough, as itself. + assert!(run( + &borough, + ROOM_AWAKE, + "a0", + awake("pit.20.0"), + "zone:pit.20.0" + ) + .await + .is_err()); + assert!( + run(&borough, ROOM_AWAKE, "a1", awake("pit.1.1"), "zone:pit.2.2") + .await + .is_err() + ); + let armed = run(&borough, ROOM_AWAKE, "a2", awake("pit.1.1"), "zone:pit.1.1") + .await + .unwrap(); + assert_eq!(armed["changed"], true, "the first room arms the cycle"); + let again = run(&borough, ROOM_AWAKE, "a3", awake("pit.2.1"), "zone:pit.2.1") + .await + .unwrap(); + assert_eq!(again["changed"], false, "armed once"); + + clock.advance(Duration::from_millis(EVENT_FIRST_MS)); + assert_eq!(borough.fire_due_schedules().await.unwrap().fired.len(), 1); + let b = borough.load().await.unwrap().unwrap().state(); + assert_eq!(b.event, EVENTS[0]); + assert_eq!(b.event_until_ms, T0 + EVENT_FIRST_MS + EVENT_LENGTH_MS); + clock.advance(Duration::from_millis(EVENT_LENGTH_MS)); + assert_eq!(borough.fire_due_schedules().await.unwrap().fired.len(), 1); + assert_eq!(borough.load().await.unwrap().unwrap().state().event, ""); + clock.advance(Duration::from_millis(EVENT_GAP_MS)); + borough.fire_due_schedules().await.unwrap(); + assert_eq!( + borough.load().await.unwrap().unwrap().state().event, + EVENTS[1], + "the next kind" + ); + + // Outward: every start and end is a domain event (the read model's rows). + let names: Vec = borough + .durable_outbox() + .unwrap() + .into_iter() + .map(|m| m.event_type) + .collect(); + assert_eq!( + names + .iter() + .filter(|n| *n == "borough.event_started") + .count(), + 2 + ); + assert_eq!( + names.iter().filter(|n| *n == "borough.event_ended").count(), + 1 + ); + + // Nobody about for long: the cycle stops after the running event. + clock.advance(Duration::from_millis(EVENT_IDLE_MS)); + borough.fire_due_schedules().await.unwrap(); + let b = borough.load().await.unwrap().unwrap(); + assert!(b.event.is_empty() && !b.armed, "stopped"); +} diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts b/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts index 2b523df3e..944647547 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts @@ -1,29 +1,81 @@ // @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']; /** `pit_rules`, as the client decodes it. */ 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, 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. */ 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, hysteresis: 0.5, maxRoom: 1000000, - biomes: ['highlands', 'steppe', 'forest', 'meadow'] + biomes: ['highlands', 'steppe', 'forest', 'meadow'], + wakeReach: 11, + wakeRelease: 19 }; + +/** `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 b98152277..1c7321279 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; @@ -10,14 +20,19 @@ export interface MobTier { readonly tele: number; } -/** Record `MoveState` (`#[derive(GameRecord)]`). */ -export interface MoveState { +/** Record `Obstacle` (`#[derive(GameRecord)]`). */ +export interface Obstacle { readonly x: number; readonly y: number; - readonly vx: number; - readonly vy: number; - readonly dash: number; - readonly cd: number; + readonly r: number; + readonly look: number; +} + +/** Record `Pillar` (`#[derive(GameRecord)]`). */ +export interface Pillar { + readonly x: number; + readonly y: number; + readonly r: number; } /** Record `PitRoomContext` (`#[derive(GameRecord)]`). */ @@ -39,6 +54,12 @@ 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; + readonly pattern: number; + readonly obstacles: readonly Obstacle[]; } /** Record `PitRules` (`#[derive(GameRecord)]`). */ @@ -48,18 +69,41 @@ 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)]`). */ +export interface PitState { + readonly x: number; + readonly y: number; + readonly vx: number; + readonly vy: number; + readonly dash: number; + readonly cd: number; + readonly z: number; + readonly vz: number; } /** Record `PitWorld` (`#[derive(GameRecord)]`). */ 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; readonly hysteresis: number; readonly maxRoom: number; readonly biomes: readonly string[]; + readonly wakeReach: number; + readonly wakeRelease: number; } /** Record `Profile` (`#[derive(GameRecord)]`). */ @@ -84,6 +128,56 @@ const PROFILE_TYPE: WireType = { ] }; +/** Record `Reach` (`#[derive(GameRecord)]`). */ +export interface Reach { + readonly near: number; +} + +/** Record `ReachContext` (`#[derive(GameRecord)]`). */ +export interface ReachContext { + readonly x: number; + readonly y: number; + readonly near: number; +} + +/** 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 event: string; + readonly eventUntilMs: number; + 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' } }], + ['event', { type: 'short_str' }], + ['eventUntilMs', { type: 'u64' }], + ['cityBossesDown', { type: 'u32' }], + ['banner', { type: 'short_str' }], + ] +}; + /** Event `hello` (id 1). */ export interface HelloEvent { readonly entity: number; @@ -95,6 +189,7 @@ export interface HelloEvent { readonly seed: number; readonly restored: boolean; readonly roomKills: number; + readonly viewRadius: number; } const HELLO_LAYOUT: PayloadLayout = [ @@ -107,6 +202,7 @@ const HELLO_LAYOUT: PayloadLayout = [ ['seed', { type: 'u32' }], ['restored', { type: 'bool' }], ['roomKills', { type: 'u32' }], + ['viewRadius', { type: 'f32' }], ]; /** Event `profile` (id 2). */ @@ -175,6 +271,58 @@ 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' }], +]; + +/** 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' }], +]; + +/** 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; @@ -190,6 +338,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, @@ -197,6 +355,10 @@ export const EVENT = { kill: 3, saved: 4, died: 6, + world: 7, + bossDown: 8, + shot: 9, + stale: 10, } as const; /** Event payload types by name. */ @@ -206,6 +368,10 @@ export interface Events { kill: KillEvent; saved: SavedEvent; died: DiedEvent; + world: WorldEvent; + bossDown: BossDownEvent; + shot: ShotEvent; + stale: StaleEvent; } /** Codecs of every event. */ @@ -240,6 +406,30 @@ 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 + }, + shot: { + id: 9, + name: 'shot', + 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`. */ @@ -262,11 +452,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. */ @@ -277,6 +471,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/input.ts b/examples/game/sveltekit/src/lib/generated/mobpit/input.ts index 7914746a8..1940d55a1 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; @@ -17,6 +21,10 @@ export const BITS = { buttons: { dash: 1, attack: 2, + jump: 4, + fire: 8, + noComp: 16, + aim: 32, }, } as const; @@ -28,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: 3 }, + { buttons: 63 }, + undefined, + undefined, + undefined, + undefined, ]; /** Payload bytes of one input frame. */ diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/predict.ts b/examples/game/sveltekit/src/lib/generated/mobpit/predict.ts index fcfeb0e96..844c5306e 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/predict.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/predict.ts @@ -1,8 +1,9 @@ // @generated by `distributed game-client` from game surface `mobpit` (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 { createWasmPrediction, type PredictStateLayout, type WasmPrediction, type WasmSource, type PayloadLayout } from '@hops-ops/distributed/game'; +import type { PitRoomContext } from './events.ts'; import { encodeInput, type PitInput } from './input.ts'; /** The predicted step: `#[predict]` fn `pit_step` of Cargo package `game_sim`, predicting kind `player`. */ @@ -10,11 +11,11 @@ export const PREDICT = { kind: 'player', export: 'pit_step', package: 'game_sim', - state: 'MoveState', + state: 'PitState', abi: 1, } as const; -/** Predicted `player` fields (record `MoveState`): wire field name and codec type, in record order. */ +/** Predicted `player` fields (record `PitState`): wire field name and codec type, in record order. */ export const PREDICT_STATE: PredictStateLayout = [ ['x', { type: 'f32' }], ['y', { type: 'f32' }], @@ -22,13 +23,19 @@ export const PREDICT_STATE: PredictStateLayout = [ ['vy', { type: 'f32' }], ['dash', { type: 'u8' }], ['cd', { type: 'u8' }], + ['z', { type: 'f32' }], + ['vz', { type: 'f32' }], ]; -/** The step takes no context. */ -export type PredictContext = undefined; +/** What the step reads besides state and input (`prediction.setContext`). */ +export type PredictContext = PitRoomContext; -/** The step takes no context. */ -export const PREDICT_CONTEXT = undefined; +/** Layout of the context record `PitRoomContext`. */ +export const PREDICT_CONTEXT: PayloadLayout | undefined = [ + ['seed', { type: 'u32' }], + ['x', { type: 'i32' }], + ['y', { type: 'i32' }], +]; /** The compiled step (`distributed game-wasm`); bundlers emit it as an asset. */ export const PREDICT_WASM_URL: URL = new URL('./wasm/mobpit.wasm', import.meta.url); @@ -38,7 +45,7 @@ export type MobpitPrediction = WasmPrediction; /** * Load the compiled step (default `PREDICT_WASM_URL`) as prediction: `predict` - * leaves the state unchanged until `ready` resolves. + * leaves the state unchanged until `ready` resolves and a context is set. */ export function createMobpitPrediction(source: WasmSource = PREDICT_WASM_URL): MobpitPrediction { return createWasmPrediction({ diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/pures.ts b/examples/game/sveltekit/src/lib/generated/mobpit/pures.ts index e2d25d5c2..3bcbad6f1 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/pures.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/pures.ts @@ -3,7 +3,17 @@ // Do not edit: change the Rust definitions and regenerate. import { createWasmPures, type WasmPures, type WasmSource, type WireType } from '@hops-ops/distributed/game'; -import type { PitRoomContext, PitRoomLayout } from './events.ts'; +import type { PitRoomContext, PitRoomLayout, Reach, ReachContext } from './events.ts'; + +const OBSTACLE_TYPE: WireType = { + type: 'record', + fields: [ + ['x', { type: 'f32' }], + ['y', { type: 'f32' }], + ['r', { type: 'f32' }], + ['look', { type: 'u8' }], + ] +}; const PIT_ROOM_CONTEXT_TYPE: WireType = { type: 'record', @@ -29,12 +39,35 @@ 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' }], + ['pattern', { type: 'u8' }], + ['obstacles', { type: 'list', item: OBSTACLE_TYPE }], + ] +}; + +const REACH_TYPE: WireType = { + type: 'record', + fields: [ + ['near', { type: 'u8' }], + ] +}; + +const REACH_CONTEXT_TYPE: WireType = { + type: 'record', + fields: [ + ['x', { type: 'f32' }], + ['y', { type: 'f32' }], + ['near', { type: 'u8' }], ] }; /** The pure functions (`#[game_pure]`) of Cargo package `game_sim`: wrapper name → module export. */ export const PURES = { pitRoom: { export: 'pit_room', abi: 1 }, + roomsInReach: { export: 'rooms_in_reach', abi: 1 }, } as const; /** The compiled module (`distributed game-wasm`; shared with the predicted step); bundlers emit it as an asset. */ @@ -44,6 +77,8 @@ export const PURES_WASM_URL: URL = new URL('./wasm/mobpit.wasm', import.meta.url export interface MobpitPureFns { /** `pit_room` (`dg_pure_pit_room`). */ pitRoom(arg: PitRoomContext): PitRoomLayout; + /** `rooms_in_reach` (`dg_pure_rooms_in_reach`). */ + roomsInReach(arg: ReachContext): Reach; } /** The `mobpit` pure functions plus `ready` / `loaded()` / `module()`. */ @@ -56,5 +91,6 @@ export type MobpitPures = WasmPures; export function createMobpitPures(source: WasmSource = PURES_WASM_URL): MobpitPures { return createWasmPures(source, { pitRoom: { exportName: 'pit_room', arg: PIT_ROOM_CONTEXT_TYPE, output: PIT_ROOM_LAYOUT_TYPE }, + roomsInReach: { exportName: 'rooms_in_reach', arg: REACH_CONTEXT_TYPE, output: REACH_TYPE }, }); } diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/schema.ts b/examples/game/sveltekit/src/lib/generated/mobpit/schema.ts index cf619af1f..497a6ca7e 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/schema.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/schema.ts @@ -27,6 +27,10 @@ export interface PlayerFields { readonly dash: number; /** `u8` */ readonly cd: number; + /** `quant [-4, 3.9995117] / 14 bits` */ + readonly z: number; + /** `quant [-16, 15.9921875] / 12 bits` */ + readonly vz: number; /** `short_str`, priority 0.2 */ readonly name: string; /** `u8` */ @@ -39,6 +43,8 @@ export interface PlayerFields { readonly level: number; /** `bool` */ readonly dead: boolean; + /** `u8` */ + readonly shot: number; } /** True for a `player` entity. */ @@ -56,12 +62,15 @@ export function playerFields(entity: { readonly fields: EntityFields }): PlayerF vy: fieldNumber(f, 'vy'), dash: fieldNumber(f, 'dash'), cd: fieldNumber(f, 'cd'), + z: fieldNumber(f, 'z'), + vz: fieldNumber(f, 'vz'), name: fieldString(f, 'name'), hp: fieldNumber(f, 'hp'), face: fieldNumber(f, 'face'), swing: fieldNumber(f, 'swing'), level: fieldNumber(f, 'level'), dead: fieldBool(f, 'dead'), + shot: fieldNumber(f, 'shot'), }; } @@ -170,12 +179,15 @@ export const FIELD_TYPES = { vy: { type: 'quant', min: -16, max: 15.9921875, bits: 12 }, dash: { type: 'u8' }, cd: { type: 'u8' }, + z: { type: 'quant', min: -4, max: 3.99951171875, bits: 14 }, + vz: { type: 'quant', min: -16, max: 15.9921875, bits: 12 }, name: { type: 'short_str' }, hp: { type: 'u8' }, face: { type: 'angle', bits: 8 }, swing: { type: 'u8' }, level: { type: 'u16' }, dead: { type: 'bool' }, + shot: { type: 'u8' }, }, mob: { x: { type: 'quant', min: -20, max: 20, bits: 14 }, @@ -204,17 +216,20 @@ export const FIELD_TYPES = { /** 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 }, + x: { mode: 'ease', durationMs: 150, snapAbove: 2 }, + y: { mode: 'ease', durationMs: 150, snapAbove: 2 }, vx: { mode: 'snap' }, vy: { mode: 'snap' }, dash: { mode: 'snap' }, cd: { mode: 'snap' }, + z: { mode: 'ease', durationMs: 100 }, + vz: { mode: 'snap' }, name: { mode: 'replace' }, hp: { mode: 'replace' }, face: { mode: 'replace' }, 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 4299b39e5..b95ed46f2 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/surface.json +++ b/examples/game/sveltekit/src/lib/generated/mobpit/surface.json @@ -1,5 +1,65 @@ { "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": { + "item": { + "type": "short_str" + }, + "type": "list" + }, + "value": [ + "pillars", + "colonnade", + "ring", + "rubble", + "hall", + "grove" + ] + }, { "name": "pit_rules", "type": { @@ -9,6 +69,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", @@ -33,6 +105,11 @@ "radius": 0.800000011920929, "tele": 2.5999999046325684, "tier": 3 + }, + { + "radius": 1.2000000476837158, + "tele": 3.200000047683716, + "tier": 4 } ] } @@ -50,14 +127,80 @@ "forest", "meadow" ], + "dash_cooldown": 45, "default_seed": 186474221, "door_half": 2.5, "half": 20.0, "hysteresis": 0.5, "max_room": 1000000, "passage": 2.0, - "room_size": 40.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, + "wake_reach": 11.0, + "wake_release": 19.0 } + }, + { + "name": "weather", + "type": { + "item": { + "type": "short_str" + }, + "type": "list" + }, + "value": [ + "storm", + "rain", + "clearing", + "clear" + ] } ], "events": [ @@ -119,6 +262,12 @@ "type": { "type": "u32" } + }, + { + "name": "view_radius", + "type": { + "type": "f32" + } } ], "kind": "fields" @@ -284,6 +433,125 @@ ], "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" + } + }, + { + "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" + } + }, + { + "id": 10, + "name": "stale", + "payload": { + "fields": [ + { + "name": "gate", + "type": { + "type": "short_str" + } + }, + { + "name": "version", + "type": { + "type": "short_str" + } + } + ], + "kind": "fields" + } } ], "input": { @@ -311,12 +579,46 @@ { "buttons": [ "dash", - "attack" + "attack", + "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" @@ -327,7 +629,8 @@ { "correction": { "durationMs": 150, - "mode": "ease" + "mode": "ease", + "snapAbove": 2.0 }, "name": "x", "priority": 1.0, @@ -341,7 +644,8 @@ { "correction": { "durationMs": 150, - "mode": "ease" + "mode": "ease", + "snapAbove": 2.0 }, "name": "y", "priority": 1.0, @@ -398,6 +702,33 @@ "type": "u8" } }, + { + "correction": { + "durationMs": 100, + "mode": "ease" + }, + "name": "z", + "priority": 1.0, + "type": { + "bits": 14, + "max": 3.9995117, + "min": -4.0, + "type": "quant" + } + }, + { + "correction": { + "mode": "snap" + }, + "name": "vz", + "priority": 1.0, + "type": { + "bits": 12, + "max": 15.9921875, + "min": -16.0, + "type": "quant" + } + }, { "correction": { "mode": "replace" @@ -458,6 +789,16 @@ "type": { "type": "bool" } + }, + { + "correction": { + "mode": "replace" + }, + "name": "shot", + "priority": 1.0, + "type": { + "type": "u8" + } } ], "id": 1, @@ -654,15 +995,38 @@ ], "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", "predict": { "abi": 1, + "context": "PitRoomContext", "export": "pit_step", "kind": "player", "package": "game_sim", - "state": "MoveState" + "state": "PitState" }, "protocolVersion": 1, "pures": [ @@ -678,72 +1042,137 @@ "type": "record" }, "package": "game_sim" + }, + { + "abi": 1, + "arg": { + "name": "ReachContext", + "type": "record" + }, + "export": "rooms_in_reach", + "output": { + "name": "Reach", + "type": "record" + }, + "package": "game_sim" } ], "records": [ { "fields": [ { - "name": "tier", + "name": "version", "type": { - "type": "u8" + "type": "short_str" } }, { - "name": "radius", + "name": "name", "type": { - "type": "f32" + "type": "short_str" } }, { - "name": "tele", + "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": "MobTier" + "name": "ContentPack" }, { "fields": [ { - "name": "x", + "name": "tier", "type": { - "type": "f32" + "type": "u8" } }, { - "name": "y", + "name": "radius", "type": { "type": "f32" } }, { - "name": "vx", + "name": "tele", + "type": { + "type": "f32" + } + } + ], + "name": "MobTier" + }, + { + "fields": [ + { + "name": "x", "type": { "type": "f32" } }, { - "name": "vy", + "name": "y", "type": { "type": "f32" } }, { - "name": "dash", + "name": "r", "type": { - "type": "u8" + "type": "f32" } }, { - "name": "cd", + "name": "look", "type": { "type": "u8" } } ], - "name": "MoveState" + "name": "Obstacle" + }, + { + "fields": [ + { + "name": "x", + "type": { + "type": "f32" + } + }, + { + "name": "y", + "type": { + "type": "f32" + } + }, + { + "name": "r", + "type": { + "type": "f32" + } + } + ], + "name": "Pillar" }, { "fields": [ @@ -791,6 +1220,46 @@ }, "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": "pattern", + "type": { + "type": "u8" + } + }, + { + "name": "obstacles", + "type": { + "item": { + "name": "Obstacle", + "type": "record" + }, + "type": "list" + } } ], "name": "PitRoomLayout" @@ -833,10 +1302,102 @@ "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" }, + { + "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": "z", + "type": { + "type": "f32" + } + }, + { + "name": "vz", + "type": { + "type": "f32" + } + } + ], + "name": "PitState" + }, { "fields": [ { @@ -851,6 +1412,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": { @@ -889,6 +1472,18 @@ }, "type": "list" } + }, + { + "name": "wake_reach", + "type": { + "type": "f32" + } + }, + { + "name": "wake_release", + "type": { + "type": "f32" + } } ], "name": "PitWorld" @@ -934,6 +1529,40 @@ ], "name": "Profile" }, + { + "fields": [ + { + "name": "near", + "type": { + "type": "u8" + } + } + ], + "name": "Reach" + }, + { + "fields": [ + { + "name": "x", + "type": { + "type": "f32" + } + }, + { + "name": "y", + "type": { + "type": "f32" + } + }, + { + "name": "near", + "type": { + "type": "u8" + } + } + ], + "name": "ReachContext" + }, { "fields": [ { @@ -956,6 +1585,98 @@ } ], "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": "event", + "type": { + "type": "short_str" + } + }, + { + "name": "event_until_ms", + "type": { + "type": "u64" + } + }, + { + "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/Events.graphql b/examples/game/sveltekit/src/lib/pit/Events.graphql new file mode 100644 index 000000000..4f2537a93 --- /dev/null +++ b/examples/game/sveltekit/src/lib/pit/Events.graphql @@ -0,0 +1,13 @@ +# The world events running now (mobpit-service's `boroughs`, projected from +# the borough cells' domain events: each borough schedules its own events +# with `ctx.schedule`). SSR seeds it, `@live` keeps it current as events +# start and end anywhere in the city. +query Events @load @live { + boroughs(where: { event: { _neq: "" } }, order_by: [{ borough: asc }]) { + borough + event + event_until_ms + bosses_down + weather + } +} diff --git a/examples/game/sveltekit/src/lib/pit/Events.svelte b/examples/game/sveltekit/src/lib/pit/Events.svelte new file mode 100644 index 000000000..7c2506eb5 --- /dev/null +++ b/examples/game/sveltekit/src/lib/pit/Events.svelte @@ -0,0 +1,28 @@ + + +
    + {#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/lib/pit/Rooms.svelte b/examples/game/sveltekit/src/lib/pit/Rooms.svelte index 64f9e359c..91e36584b 100644 --- a/examples/game/sveltekit/src/lib/pit/Rooms.svelte +++ b/examples/game/sveltekit/src/lib/pit/Rooms.svelte @@ -4,8 +4,9 @@ let { here, debug = true, + near = [], ontoast - }: { here: string | undefined; debug?: boolean; ontoast?: (text: string) => void } = $props(); + }: { here: string | undefined; debug?: boolean; near?: readonly string[]; ontoast?: (text: string) => void } = $props(); // Island: promoted to the page's `@load` (SSR), then live. const rooms = Rooms.use(); @@ -53,10 +54,12 @@
{#if cell.row && cell.row.players > 0}{cell.row.players}{/if} {#if debug && cell.row?.node}{cell.row.node.replace(/^node-/, '')}{/if} @@ -64,12 +67,14 @@ {/each} {/each}
-
■ active ■ dormant ■ asleep ■ never ran
+
■ active ■ dormant ■ asleep ■ never ran □ kept awake
diff --git a/examples/game/sveltekit/tests/pit.test.mjs b/examples/game/sveltekit/tests/pit.test.mjs index 770608748..75926082c 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. * @@ -101,22 +102,42 @@ async function walkThrough(page, side) { const goal = { east: [21, 1.7], west: [-21, 1.7], north: [1.7, 21], south: [1.7, -21] }[side]; const room = () => page.evaluate(() => document.querySelector('canvas')?.dataset.room); const world = () => page.evaluate(() => document.querySelector('canvas')?.dataset.world?.split(',').map(Number)); + const deaths = () => page.evaluate(() => Number(document.querySelector('canvas')?.dataset.deaths ?? 0)); const from = await room(); - await page.evaluate(() => (document.querySelector('canvas').dataset.jump = '0')); - const before = await world(); - const arrived = await walkInto(page, goal, room, from); - // The new room's first snapshot, then the held prediction resumes there. - await page.waitForSelector(`[data-testid=zone]:text-is("${arrived}")`, { timeout: 10_000 }); - await page.waitForTimeout(500); - const jump = Number(await page.evaluate(() => document.querySelector('canvas').dataset.jump)); - 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}`); - 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; - assert.ok(along > 0 && Math.hypot(after[0] - before[0], after[1] - before[1]) < 40, `${before} → ${after}`); - return arrived; + for (let attempt = 1; ; attempt += 1) { + // Measured only on a walk you survive: a death respawns you at a + // spawn point (a cut by design, not a doorway jump). + await page.waitForFunction(() => document.querySelector('canvas')?.dataset.dead === 'false', null, { timeout: 15_000 }); + const died = await deaths(); + await page.evaluate(() => (document.querySelector('canvas').dataset.jump = '0')); + const before = await world(); + const arrived = await walkInto(page, goal, room, from); + // The new room's first snapshot, then the held prediction resumes there. + await page.waitForSelector(`[data-testid=zone]:text-is("${arrived}")`, { timeout: 10_000 }); + await page.waitForTimeout(500); + if ((await deaths()) !== died) { + assert.ok(attempt < 3, `died on every walk ${side} out of ${from}`); + console.error(`walk ${side} out of ${from}: died on the way (attempt ${attempt}); walking again`); + if (arrived !== from) { + // Back (unmeasured), then the measured walk again. + const back = { east: [-21, 1.7], west: [21, 1.7], north: [1.7, -21], south: [1.7, 21] }[side]; + await page.waitForFunction(() => document.querySelector('canvas')?.dataset.dead === 'false', null, { timeout: 15_000 }); + assert.equal(await walkInto(page, back, room, arrived), from); + await page.waitForSelector(`[data-testid=zone]:text-is("${from}")`, { timeout: 10_000 }); + } + continue; + } + const jump = Number(await page.evaluate(() => document.querySelector('canvas').dataset.jump)); + 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; + assert.ok(along > 0 && Math.hypot(after[0] - before[0], after[1] - before[1]) < 40, `${before} → ${after}`); + return arrived; + } } async function walkInto(page, goal, room, from) { @@ -145,6 +166,35 @@ async function walkInto(page, goal, room, from) { } } +/** Walk `page`'s player to room-local `goal` in its room (keyboard). */ +async function walkTo(page, goal) { + const held = new Set(); + const hold = async (keys) => { + for (const k of [...held]) if (!keys.includes(k)) (await page.keyboard.up(k), held.delete(k)); + for (const k of keys) if (!held.has(k)) (await page.keyboard.down(k), held.add(k)); + }; + try { + for (const start = Date.now(); Date.now() - start < 30_000; ) { + const me = await page.evaluate(() => document.querySelector('canvas')?.dataset.me); + const [x, y] = (me ?? '0,0').split(',').map(Number); + if (Math.hypot(goal[0] - x, goal[1] - y) < 0.6) return; + const keys = []; + if (goal[0] - x > 0.3) keys.push('KeyD'); + if (goal[0] - x < -0.3) keys.push('KeyA'); + if (goal[1] - y > 0.3) keys.push('KeyW'); + if (goal[1] - y < -0.3) keys.push('KeyS'); + await hold(keys); + await page.waitForTimeout(60); + } + throw new Error(`walking to ${goal} timed out`); + } finally { + await hold([]); + } +} + +/** Rooms the page keeps awake (proximity wake). */ +const nearRooms = (page) => page.evaluate(() => (document.querySelector('[data-testid=near]')?.dataset.rooms ?? '').split(' ').filter(Boolean)); + /** The minimap's view of `zone` (the zone status read model, live). */ const roomStatus = (page, zone) => page.getAttribute(`[data-testid="room-${zone}"]`, 'data-status'); @@ -283,6 +333,44 @@ 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}`); + // Jump (Q): the predicted height rises and comes back to the floor. + await page.keyboard.down('KeyQ'); + await page.waitForFunction(() => Number(document.querySelector('canvas')?.dataset.z ?? 0) > 0.5, null, { timeout: 5_000 }); + await page.keyboard.up('KeyQ'); + await page.waitForFunction(() => Number(document.querySelector('canvas')?.dataset.z ?? 1) === 0, null, { timeout: 5_000 }); + + // 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); + // The RTT is smoothed (1/8 per 500 ms ping) and seeded by the first + // sample, which can be slow while the page boots: let it settle to + // a loopback value before taking the baseline. + await page + .waitForFunction(() => Number(document.querySelector('[data-testid=rtt]')?.textContent) < 40, null, { timeout: 15_000 }) + .catch(async (e) => { + throw new Error(`base rtt stayed ${await rtt()}: ${e}`); + }); + 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 + 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'); + 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); + + // The world above the room (its hood's cell, the borough's weather): + // nobody felled a boss yet, so the storm darkens the view. + await page.waitForSelector('[data-testid=weather]:text-is("storm")', { timeout: 15_000 }); + assert.equal(await page.textContent('[data-testid=hood-tier]'), '0'); + await page.waitForFunction(() => Number(document.querySelector('canvas')?.dataset.dark ?? 0) > 0.3, null, { timeout: 5_000 }); + // 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 }); @@ -329,6 +417,22 @@ test('sign in, join, move, kill, walk through rooms on celld, live read models f { timeout: 15_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]'); + // Rooms. The overlay shows which celld node runs the entrance (celld // chose it; the game only shows it). await page.waitForSelector('[data-testid=room-pit\\.0\\.0][data-status=active]', { timeout: 15_000 }); @@ -343,6 +447,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). @@ -369,7 +477,15 @@ 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. + // Both step away from the doorways they came through (standing in one + // keeps the room behind it awake: "wake rooms in reach"). + assert.deepEqual(await nearRooms(page), ['pit.0.0'], 'Ana at the doorway keeps the entrance awake'); + await walkTo(page, [3, 3]); + await walkTo(bob, [2, 1]); + await page.waitForFunction(() => document.querySelector('[data-testid=near]')?.dataset.rooms === '', null, { timeout: 10_000 }); + // The left room goes dormant, then asleep, live on both minimaps — + // although Bob, next door, watches it (a passive 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'); @@ -378,6 +494,22 @@ test('sign in, join, move, kill, walk through rooms on celld, live read models f await page.waitForSelector('[data-testid=toasts] .toast:has-text("restored from hibernation")', { timeout: 15_000 }); await page.waitForSelector('[data-testid=room-pit\\.0\\.0][data-status=active]', { timeout: 15_000 }); await bob.waitForSelector('[data-testid=room-pit\\.0\\.0][data-status=active]', { timeout: 15_000 }); + // Proximity wake (on by default): walking up to the west doorway wakes + // the room behind it, which never ran, before Ana gets there; N turns + // it off and the room is let go. + assert.equal(await page.isChecked('[data-testid=wake]'), true, 'wake rooms in reach: on by default'); + await walkTo(page, [-3, 3]); + await walkTo(page, [-13, 1.7]); + await page.waitForFunction(() => (document.querySelector('[data-testid=near]')?.dataset.rooms ?? '').split(' ').includes('pit.-1.0'), null, { timeout: 10_000 }); + await page.waitForSelector('[data-testid="room-pit.-1.0"][data-status=active]', { timeout: 20_000 }); + assert.equal(await page.textContent('[data-testid=zone]'), 'pit.0.0', 'woken before the doorway'); + console.error(`proximity wake: pit.-1.0 woke with Ana 7 m from its doorway (kept awake: ${await nearRooms(page)})`); + await page.keyboard.press('KeyN'); + assert.equal(await page.isChecked('[data-testid=wake]'), false); + assert.equal(await page.textContent('[data-testid=near]'), 'off'); + await page.waitForSelector('[data-testid="room-pit.-1.0"][data-status=hibernated]', { timeout: 30_000 }); + await page.keyboard.press('KeyN'); + assert.equal(await page.isChecked('[data-testid=wake]'), true); // A kill in the restored room settles and the read model follows live. const before = Number(await page.textContent('[data-testid=kills]')); await page.check('[data-testid=autohunt]'); @@ -399,6 +531,36 @@ 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 }); + + // Live content update (dev admin): every room freezes with the change + // notes, switches to the pack and resumes; the page patches live. + assert.equal(await page.getAttribute('[data-testid=content]', 'data-version'), ''); + const notes = 'Frostfall: slower, tougher mobs.'; + const started = await page.evaluate( + (n) => fetch('/api/dev/rollout', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ version: 'frostfall', notes: n }) }).then((r) => r.status), + notes + ); + assert.equal(started, 200); + await page.waitForSelector('[data-testid=frozen]', { timeout: 10_000 }); + assert.match(await page.textContent('[data-testid=frozen]'), /Frostfall: slower, tougher mobs\./); + await page.waitForSelector('[data-testid=frozen]', { state: 'detached', timeout: 15_000 }); + await page.waitForFunction(() => document.querySelector('[data-testid=content]')?.getAttribute('data-version') === 'frostfall', null, { timeout: 5_000 }); + assert.equal(await page.textContent('[data-testid=world-content]'), 'Frostfall'); + assert.equal(await page.$('[data-testid=stale]'), null, 'patched live: not stale'); + console.error(`content: rolled out ${await page.textContent('[data-testid=content]')} (froze with the notes, resumed)`); + // 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}<`)); diff --git a/js/README.md b/js/README.md index 3a09c1899..aeb77d90c 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'); @@ -771,7 +772,16 @@ 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`; unless +they are *near*: `watch.setNear(zones)` / `game.setNear(zone, near)` / +`observe(zone, { near: true })` tell the zone without reconnecting, and a +near watcher whose ticket grants `wake` — `mintGameTicket({ wake })`, the +ticket handler's server-side `wake(context)` — wakes the zone and keeps it +awake; a sleeping zone that becomes near is retried at once) 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/client.ts b/js/src/game/client.ts index a74e44d96..faa66c6f0 100644 --- a/js/src/game/client.ts +++ b/js/src/game/client.ts @@ -286,12 +286,29 @@ export class GameClient { return zone.connection.connect(); } - /** Observe a neighbouring zone (read-only; merged into `render`). */ - observe(zoneId: string): Promise { + /** + * Observe a neighbouring zone (read-only; merged into `render`). With + * `near` the observer says it is near the zone (celld zones: with a + * ticket that grants `wake` it wakes the zone and keeps it awake; see + * `setNear`). + */ + observe(zoneId: string, options: { near?: boolean } = {}): Promise { this.#assertOpen(); const existing = this.#zones.get(zoneId); - if (existing !== undefined) return existing.connection.connect(); - return this.#adopt(zoneId, 'observer').connection.connect(); + if (existing !== undefined) { + if (options.near !== undefined && existing.role === 'observer') existing.connection.setNear(options.near); + return existing.connection.connect(); + } + return this.#adopt(zoneId, 'observer', undefined, options.near).connection.connect(); + } + + /** + * Say whether an observed zone is near (keep it awake) or not (a passive + * watcher), without reconnecting. No-op for zones not observed. + */ + setNear(zoneId: string, near: boolean): void { + const zone = this.#zones.get(zoneId); + if (zone?.role === 'observer') zone.connection.setNear(near); } /** Stop observing a zone. */ @@ -463,7 +480,12 @@ export class GameClient { if (this.#closed) throw new Error('GameClient is closed'); } - #connectionOptions(zoneId: string, role: 'authority' | 'observer', initialTicket?: string): GameConnectionOptions { + #connectionOptions( + zoneId: string, + role: 'authority' | 'observer', + initialTicket?: string, + near?: boolean + ): GameConnectionOptions { const o = this.#options; const observerTicket: GameTicketSource = o.observerTicket ?? ((request) => o.ticket({ ...request, role: 'observer' })); @@ -471,6 +493,7 @@ export class GameClient { url: o.url(zoneId), zoneId, role: role === 'authority' ? 'player' : 'observer', + near: role === 'observer' && near === true, ticket: role === 'authority' ? o.ticket : observerTicket, initialTicket, contentVersion: this.#contentVersion, @@ -489,7 +512,7 @@ export class GameClient { * same zone (observer, or retired after a handoff) hands its replica over * so already-rendered entities continue without a gap. */ - #adopt(zoneId: string, role: 'authority' | 'observer', initialTicket?: string): Zone { + #adopt(zoneId: string, role: 'authority' | 'observer', initialTicket?: string, near?: boolean): Zone { const prior = this.#zones.get(zoneId) ?? this.#retired.get(zoneId); const replica = prior?.replica ?? @@ -498,7 +521,7 @@ export class GameClient { staleGraceMs: this.#options.staleGraceMs, historyMs: this.#options.historyMs }); - const connection = new GameConnection(this.#connectionOptions(zoneId, role, initialTicket)); + const connection = new GameConnection(this.#connectionOptions(zoneId, role, initialTicket, near)); const zone: Zone = { zoneId, role, connection, replica, offs: [], firstDeltaSeen: false }; if (prior !== undefined) { // The prior connection keeps feeding the shared replica until the diff --git a/js/src/game/connection.ts b/js/src/game/connection.ts index c7a4be5b2..23cc6f4f0 100644 --- a/js/src/game/connection.ts +++ b/js/src/game/connection.ts @@ -85,6 +85,12 @@ export type GameConnectionOptions = Readonly<{ zoneId: string; /** Expected role (informational; the ticket decides). Default `player`. */ role?: GameRole; + /** + * Observers: start near the zone (Hello `near`). On celld zones a near + * observer whose ticket grants `wake` wakes the zone and keeps it + * awake; see `setNear`. Default false (a passive watcher). + */ + near?: boolean; ticket: GameTicketSource; /** Ticket for the first Hello only (e.g. a Handoff transfer ticket). */ initialTicket?: string; @@ -210,6 +216,7 @@ export class GameConnection { readonly #baseUrl: string; #url: string; #contentVersion: string; + #near: boolean; #redirects = 0; #lastResumeVersion: string | undefined; #attempt: Attempt | undefined; @@ -233,6 +240,7 @@ export class GameConnection { this.#baseUrl = options.url; this.#url = options.url; this.#contentVersion = options.contentVersion ?? ''; + this.#near = options.near ?? false; this.#initialTicket = options.initialTicket; this.#scheduler = options.scheduler ?? defaultScheduler; const factory = @@ -403,13 +411,36 @@ 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 }); + } + } + + /** Whether this (observer) connection says it is near the zone. */ + get near(): boolean { + return this.#near; + } + + /** + * Observers: say whether the zone is near (keep it awake — celld zones, + * with a ticket that grants `wake`) or not (a passive watcher). Sent at + * once on an open observer connection (`Near`) and in every later Hello. + */ + setNear(near: boolean): void { + const changed = near !== this.#near; + this.#near = near; + if (changed && this.#state === 'open' && this.mirror.welcome?.role === 'observer') { + this.#send({ type: 'Near', near }); + } } /** @@ -496,7 +527,8 @@ export class GameConnection { contentVersion: this.#contentVersion, capabilities: this.#options.capabilities ?? 0, ticket, - reconnectToken: token + reconnectToken: token, + near: this.#near }); }; socket.onmessage = (event) => { diff --git a/js/src/game/index.ts b/js/src/game/index.ts index 05e9b39eb..1bb9b53b1 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 b414bceb2..a86f31279 100644 --- a/js/src/game/messages.ts +++ b/js/src/game/messages.ts @@ -21,6 +21,9 @@ export const GameMessageTag = Object.freeze({ INPUT: 0x02, PING: 0x03, LEAVE: 0x04, + CONTENT: 0x05, + /** Client → server: an observer is near (keep the zone awake) or passive again. */ + NEAR: 0x06, WELCOME: 0x80, DELTA: 0x81, EVENT: 0x82, @@ -63,7 +66,11 @@ 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, + /** A near observer's ticket does not grant `wake`: it stays a passive watcher (a fresh ticket may grant it). */ + WAKE_DENIED: 11 }); export type HelloMessage = Readonly<{ @@ -73,6 +80,11 @@ export type HelloMessage = Readonly<{ capabilities: number; ticket: string; reconnectToken: string; + /** + * Observers: near the zone (celld zones: with a ticket that grants + * `wake`, wakes it and keeps it awake). Default false. + */ + near?: boolean; }>; export type InputMessage = Readonly<{ type: 'Input'; @@ -82,7 +94,11 @@ 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 }>; +/** An observer is near the zone (keep it awake, with a `wake` ticket) or passive again. */ +export type NearMessage = Readonly<{ type: 'Near'; near: boolean }>; +export type GameClientMessage = HelloMessage | InputMessage | PingMessage | LeaveMessage | ContentMessage | NearMessage; export type GameRole = 'player' | 'observer'; @@ -161,7 +177,8 @@ export function encodeClientMessage(message: GameClientMessage): Uint8Array { .string(message.contentVersion) .varuint(message.capabilities) .string(message.ticket) - .string(message.reconnectToken); + .string(message.reconnectToken) + .bool(message.near ?? false); break; case 'Input': w.u8(GameMessageTag.INPUT) @@ -175,6 +192,12 @@ export function encodeClientMessage(message: GameClientMessage): Uint8Array { case 'Leave': w.u8(GameMessageTag.LEAVE); break; + case 'Content': + w.u8(GameMessageTag.CONTENT).string(message.contentVersion); + break; + case 'Near': + w.u8(GameMessageTag.NEAR).bool(message.near); + break; } return w.finish(); } @@ -192,7 +215,8 @@ export function decodeClientMessage(bytes: Uint8Array): GameClientMessage { contentVersion: r.string(), capabilities: r.varuint(), ticket: r.string(), - reconnectToken: r.string() + reconnectToken: r.string(), + near: r.bool() }; break; case GameMessageTag.INPUT: @@ -204,6 +228,12 @@ export function decodeClientMessage(bytes: Uint8Array): GameClientMessage { case GameMessageTag.LEAVE: message = { type: 'Leave' }; break; + case GameMessageTag.CONTENT: + message = { type: 'Content', contentVersion: r.string() }; + break; + case GameMessageTag.NEAR: + message = { type: 'Near', near: r.bool() }; + break; default: throw new GameDecodeError('UNKNOWN_MESSAGE', `message type 0x${tag.toString(16)}`); } diff --git a/js/src/game/prediction.ts b/js/src/game/prediction.ts index 2b1467b0d..575684a78 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/src/game/watch.ts b/js/src/game/watch.ts new file mode 100644 index 000000000..c182a4549 --- /dev/null +++ b/js/src/game/watch.ts @@ -0,0 +1,185 @@ +/** + * 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. + * + * **Near zones** (proximity wake): `setNear(zones)` marks watched zones the + * player is close to. A near watcher says so to the zone (Hello `near`, + * then `Near` without reconnecting); on celld, when its ticket grants + * `wake` (the issuer decides, e.g. only rooms next to the player's own), it + * wakes the zone and keeps it awake. A sleeping zone that becomes near is + * retried at once. Zones that stop being near are passive watchers again. + * A watcher whose ticket did not grant `wake` (`WAKE_DENIED`, e.g. issued + * before the issuer knew where the player is) is reopened with a fresh + * ticket while its zone stays near — at once the first time, then backing + * off like a sleeping zone. + */ +import type { GameClient } from './client.js'; +import { GameErrorCode } from './messages.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; + /** + * The watched zones that are near (replaces the previous set; zones not + * watched are kept and apply once they are). + */ + setNear(zones: Iterable): void; + /** The zones last passed to `setNear`. */ + near(): ReadonlySet; +}>; + +/** + * 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 near = new Set(); + /** + * Near zones whose watcher's ticket did not grant wake: refusals in a + * row (kept across Welcomes) and when to ask for a fresh ticket. + */ + const denied = new Map(); + 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 [...denied.keys()]) if (!wanted.has(zone) || !near.has(zone)) denied.delete(zone); + for (const zone of wanted) { + const existing = client.zone(zone); + if (existing?.role === 'authority') continue; + const refusal = denied.get(zone); + if (existing?.role === 'observer' && existing.connection.state !== 'closed') { + if (!(refusal?.retry === true && refusal.at <= now())) { + client.setNear(zone, near.has(zone)); + continue; + } + // Ask the issuer again: a fresh ticket may grant wake. + refusal.retry = false; + } + const due = (backoff.get(zone)?.at ?? 0) <= now(); + // A closed watcher (refused or let go by a sleeping zone): anew, + // once its back-off is over. + if (!due) continue; + letGo(zone); + if (states.get(zone) !== 'asleep') states.set(zone, 'connecting'); + let welcomed: Promise; + try { + welcomed = client.observe(zone, { near: near.has(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('error', (zone, error) => { + if (error.code !== GameErrorCode.WAKE_DENIED || !wanted.has(zone) || !near.has(zone)) return; + const failures = (denied.get(zone)?.failures ?? 0) + 1; + const wait = failures === 1 ? 0 : Math.min(maxRetryMs, retryMs * 2 ** (failures - 2)); + denied.set(zone, { failures, at: now() + wait, retry: true }); + if (wait === 0) queueMicrotask(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, + setNear: (zones) => { + const next = new Set(zones); + let retry = false; + for (const zone of next) { + // A sleeping zone that becomes near is tried again at once + // (the near watcher may wake it). + if (!near.has(zone) && wanted.has(zone) && states.get(zone) === 'asleep') { + backoff.delete(zone); + retry = true; + } + } + const changed = next.size !== near.size || [...next].some((z) => !near.has(z)); + near = next; + if (!changed) return; + if (retry) sync(); + else for (const zone of wanted) client.setNear(zone, near.has(zone)); + }, + near: () => near, + stop: () => { + stopped = true; + clear(timer); + for (const off of offs) off(); + } + }; +} diff --git a/js/src/sveltekit/game/server.ts b/js/src/sveltekit/game/server.ts index 7fed6abad..7ce318f24 100644 --- a/js/src/sveltekit/game/server.ts +++ b/js/src/sveltekit/game/server.ts @@ -116,6 +116,13 @@ export type CreateGameTicketHandlerOptions ): GameTicketExtensions | null | undefined | Promise; + /** + * Observer tickets: whether this watcher may wake the zone and keep it + * awake while it says it is near (claim `wake`; celld zones), decided on + * the server — e.g. only zones next to where the principal plays. The + * browser cannot ask for it. Default: never. + */ + wake?(context: GameTicketClaimsContext): boolean | Promise; /** Roles a browser may ask for. Default both (`observer` owns no player). */ roles?: readonly GameTicketRole[]; /** Ticket lifetime in seconds (1..=300). Default 60. */ @@ -412,6 +419,17 @@ export function createGameTicketHandler { const messages = allMessages(); const names = messages.map((m) => m.name); - for (const required of ['hello', 'hello_reconnect', 'input', 'ping', 'leave', 'welcome', 'delta', 'delta_empty', 'event', 'pong', 'freeze', 'resume', 'handoff', 'kick', 'error', 'kick_reload', 'redirect']) { + for (const required of ['hello', 'hello_reconnect', 'input', 'ping', 'leave', 'welcome', 'delta', 'delta_empty', 'event', 'pong', 'freeze', 'resume', 'handoff', 'kick', 'error', 'kick_reload', 'redirect', 'hello_near', 'near', 'passive']) { assert.ok(names.includes(required), `fixture ${required}`); } for (const entry of messages) { @@ -432,7 +442,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('07')), /UNKNOWN_MESSAGE/); // A delta update for an unknown entity id. const { messages } = fixture('messages.json'); const delta = messages.find((m) => m.name === 'delta'); diff --git a/js/tests/game-connection.test.mjs b/js/tests/game-connection.test.mjs index df489def7..dcd919eab 100644 --- a/js/tests/game-connection.test.mjs +++ b/js/tests/game-connection.test.mjs @@ -81,7 +81,8 @@ test('handshake sends Hello with versions, capabilities and the app ticket', asy contentVersion: 'c1', capabilities: 5, ticket: 't:z1:1', - reconnectToken: '' + reconnectToken: '', + near: false }); assert.deepEqual(tickets, [{ zoneId: 'z1', role: 'player', reason: 'join' }]); assert.deepEqual(states, ['connecting', 'open']); @@ -92,6 +93,40 @@ test('handshake sends Hello with versions, capabilities and the app ticket', asy assert.equal(conn.state, 'closed'); }); +test('an observer says it is near in Hello and with Near, without reconnecting', async () => { + const network = new FakeNetwork(); + const scheduler = new FakeScheduler(); + network.onMessage = (conn, message) => { + if (message.type === 'Hello') conn.send(welcome({ zoneId: conn.zoneId, role: 'observer', serverTime: 10_000 })); + }; + const { conn } = connection(network, scheduler, { role: 'observer', near: true }); + await conn.connect(); + const server = network.last(); + assert.equal(server.lastHello.near, true); + assert.equal(conn.near, true); + conn.setNear(false); + conn.setNear(false); + conn.setNear(true); + assert.deepEqual( + server.inbox.filter((m) => m.type === 'Near'), + [ + { type: 'Near', near: false }, + { type: 'Near', near: true } + ] + ); + assert.equal(network.connections.length, 1, 'same socket'); + conn.close(); + // A player's nearness is never sent. + network.onMessage = (c, message) => { + if (message.type === 'Hello') c.send(welcome({ zoneId: c.zoneId, serverTime: 10_000 })); + }; + const player = connection(network, scheduler).conn; + await player.connect(); + player.setNear(true); + assert.deepEqual(network.last().inbox.filter((m) => m.type === 'Near'), []); + player.close(); +}); + test('ping/pong estimates offset, rtt and jitter', async () => { const network = new FakeNetwork(); const scheduler = new FakeScheduler(); diff --git a/js/tests/game-replica-prediction.test.mjs b/js/tests/game-replica-prediction.test.mjs index cd7d23ffc..9a152bc1c 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 diff --git a/js/tests/game-watch.test.mjs b/js/tests/game-watch.test.mjs new file mode 100644 index 000000000..9b3520f09 --- /dev/null +++ b/js/tests/game-watch.test.mjs @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { GameErrorCode, 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), + nearCalls: [], + observe(id, options = {}) { + client.observed.push(id); + const zone = { zoneId: id, role: 'observer', near: options.near === true, connection: { state: 'connecting' } }; + zones.set(id, zone); + // A near watcher wakes a sleeping zone (its ticket grants wake). + if ((answers.get(id) ?? 'asleep') === 'ok' || zone.near) { + zone.connection.state = 'open'; + return Promise.resolve({}); + } + zone.connection.state = 'closed'; + return Promise.reject(new Error('ASLEEP')); + }, + setNear(id, near) { + const zone = zones.get(id); + if (zone?.role !== 'observer') return; + if (zone.near !== near) client.nearCalls.push([id, near]); + zone.near = near; + }, + 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'); +}); + +test('watchZones: near zones are observed near, a sleeping one is retried at once, and released', async () => { + const client = stubClient(); + let clock = 0; + const around = (zone) => { + const n = Number(zone.slice(1)); + return [`z${n - 1}`, `z${n + 1}`]; + }; + const watch = watchZones(client, around, { now: () => clock, setInterval: () => 1, clearInterval: () => {} }); + client.answers.set('z1', 'ok'); + client.join('z2'); + await flush(); + assert.deepEqual(Object.fromEntries(watch.states()), { z1: 'watching', z3: 'asleep' }); + // Near z1 (awake, open): told without reconnecting. + const opened = client.observed.length; + watch.setNear(['z1']); + assert.equal(client.zone('z1').near, true); + assert.equal(client.observed.length, opened, 'no reconnect'); + // Near z3 (asleep, backing off): retried at once, near, and it wakes. + watch.setNear(['z1', 'z3']); + await flush(); + assert.equal(client.zone('z3').near, true); + assert.equal(watch.states().get('z3'), 'watching'); + assert.deepEqual([...watch.near()].sort(), ['z1', 'z3']); + // Released: passive again, still watched. + watch.setNear(['z3']); + assert.equal(client.zone('z1').near, false); + assert.equal(watch.states().get('z1'), 'watching'); + assert.deepEqual(client.nearCalls, [['z1', true], ['z1', false]]); + // Nothing near: z3 passive too. + watch.setNear([]); + assert.equal(client.zone('z3').near, false); + watch.stop(); +}); + +test('watchZones: a near watcher refused WAKE_DENIED asks for a fresh ticket, at once then backing off', async () => { + const client = stubClient(); + let clock = 0; + let tick = () => {}; + const watch = watchZones(client, (zone) => [zone === 'z2' ? 'z3' : 'z2'], { + now: () => clock, + setInterval: (fn) => ((tick = fn), 1), + clearInterval: () => {} + }); + client.answers.set('z3', 'ok'); + client.join('z2'); + await flush(); + assert.equal(watch.states().get('z3'), 'watching'); + const deny = () => client.emit('error', 'z3', { type: 'Error', code: GameErrorCode.WAKE_DENIED, message: 'no grant' }); + watch.setNear(['z3']); + const opened = client.observed.length; + // Not near-ready (the ticket had no grant): reopened at once. + deny(); + await flush(); + assert.equal(client.observed.length, opened + 1, 'a fresh ticket at once'); + assert.equal(client.zone('z3').near, true, 'reopened near'); + // Refused again: backs off (3 s), then tries once more. + deny(); + await flush(); + tick(); + assert.equal(client.observed.length, opened + 1, 'backing off'); + clock += 3000; + tick(); + await flush(); + assert.equal(client.observed.length, opened + 2); + // A refusal for a zone that is no longer near is ignored. + watch.setNear([]); + deny(); + clock += 60_000; + tick(); + await flush(); + assert.equal(client.observed.length, opened + 2); + assert.equal(client.zone('z3').near, false); + watch.stop(); +}); diff --git a/js/tests/sveltekit-game.test.mjs b/js/tests/sveltekit-game.test.mjs index 08b2206c3..fb82a2da6 100644 --- a/js/tests/sveltekit-game.test.mjs +++ b/js/tests/sveltekit-game.test.mjs @@ -456,3 +456,27 @@ test('useGame does nothing during SSR (no client, no connection)', async () => { rmSync(dir, { recursive: true, force: true }); } }); + +test('ticket handler: wake() grants observers the wake claim; players and the browser never get it', async () => { + const asked = []; + const { call } = harness({ + rateLimit: false, + wake: (context) => { + asked.push(context); + return context.zone === 'pit-u1'; + } + }); + const granted = await expectStatus(call({ body: { role: 'observer' } }), 200); + assert.equal(decodeClaims(granted.ticket).wake, true); + assert.equal(asked.at(-1).principal, 'u1'); + assert.equal(asked.at(-1).role, 'observer'); + const player = await expectStatus(call({ body: {} }), 200); + assert.equal('wake' in decodeClaims(player.ticket), false, 'players never carry wake'); + assert.equal(asked.length, 1, 'not asked for players'); + await expectStatus(call({ body: { role: 'observer', wake: true } }), 400); + const denied = await expectStatus(harness({ rateLimit: false, wake: () => false }).call({ body: { role: 'observer' } }), 200); + assert.equal('wake' in decodeClaims(denied.ticket), false); + const none = await expectStatus(harness({ rateLimit: false }).call({ body: { role: 'observer' } }), 200); + assert.equal('wake' in decodeClaims(none.ticket), false, 'default: never'); + await assert.rejects(mintGameTicket({ secret: SECRET, principal: 'p', zone: 'z', wake: true }), /only observer/); +});