From 140b1c02467a6355b3215120553a04c474327e38 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 17:54:49 -0500 Subject: [PATCH 01/87] feat(game): spike a Mob Pit room as a celld Durable Object One Mob Pit room runs as a celld Durable Object (wasm32 Rust): the existing ZoneHost, wire protocol and HS256 tickets, a setTimeout tick loop while occupied, a hibernation snapshot in object storage, and the celld node / isolate / activation replicated as a global `host` entity. run.sh starts a 3-node production-mode fleet on one RustFS bucket; tools/ hold the headless test and the measurement scripts. Framework changes the spike needed: a `ticket` feature (admission tickets without the axum server) and wasm32-safe tick timing in ZoneHost (no `Instant` on wasm32-unknown-unknown). Spike only: not wired into the demos. [[tasks/gr-p42-zones-as-celld-objects-spike]] --- distributed_game/Cargo.toml | 5 +- distributed_game/src/host.rs | 10 + distributed_game/src/lib.rs | 5 + distributed_game/src/net/mod.rs | 2 +- examples/game/zone-do-spike/.gitignore | 4 + examples/game/zone-do-spike/Cargo.toml | 27 + examples/game/zone-do-spike/page/main.ts | 60 ++ .../game/zone-do-spike/page/room-client.ts | 61 ++ examples/game/zone-do-spike/public/index.html | 19 + examples/game/zone-do-spike/run.sh | 87 ++ examples/game/zone-do-spike/src/lib.rs | 755 +++++++++++++++ examples/game/zone-do-spike/src/pit.rs | 859 ++++++++++++++++++ .../game/zone-do-spike/tools/baseline.mjs | 5 + examples/game/zone-do-spike/tools/bench.ts | 166 ++++ examples/game/zone-do-spike/tools/drain.ts | 28 + examples/game/zone-do-spike/tools/failover.ts | 48 + examples/game/zone-do-spike/tools/headless.ts | 65 ++ examples/game/zone-do-spike/tools/peek.ts | 11 + .../game/zone-do-spike/tools/s3-compose.yaml | 13 + examples/game/zone-do-spike/wrangler.jsonc | 20 + 20 files changed, 2248 insertions(+), 2 deletions(-) create mode 100644 examples/game/zone-do-spike/.gitignore create mode 100644 examples/game/zone-do-spike/Cargo.toml create mode 100644 examples/game/zone-do-spike/page/main.ts create mode 100644 examples/game/zone-do-spike/page/room-client.ts create mode 100644 examples/game/zone-do-spike/public/index.html create mode 100755 examples/game/zone-do-spike/run.sh create mode 100644 examples/game/zone-do-spike/src/lib.rs create mode 100644 examples/game/zone-do-spike/src/pit.rs create mode 100644 examples/game/zone-do-spike/tools/baseline.mjs create mode 100644 examples/game/zone-do-spike/tools/bench.ts create mode 100644 examples/game/zone-do-spike/tools/drain.ts create mode 100644 examples/game/zone-do-spike/tools/failover.ts create mode 100644 examples/game/zone-do-spike/tools/headless.ts create mode 100644 examples/game/zone-do-spike/tools/peek.ts create mode 100644 examples/game/zone-do-spike/tools/s3-compose.yaml create mode 100644 examples/game/zone-do-spike/wrangler.jsonc diff --git a/distributed_game/Cargo.toml b/distributed_game/Cargo.toml index 9116199d..1b101016 100644 --- a/distributed_game/Cargo.toml +++ b/distributed_game/Cargo.toml @@ -31,7 +31,10 @@ metrics = [] # Load-test bots (library + `game-bots` binary). bots = ["net", "dep:tokio-tungstenite", "dep:futures-util", "tokio/rt-multi-thread"] # axum WebSocket zone server with signed-ticket admission (+ /metrics). -net = ["runtime", "metrics", "dep:axum", "dep:hmac", "dep:sha2", "dep:base64", "tokio/net", "tokio/macros", "tokio/io-util"] +# HMAC-signed admission tickets (`ticket`) without the axum server: what a +# celld zone host (wasm32) needs to admit players (spike gr-p42). +ticket = ["dep:hmac", "dep:sha2", "dep:base64"] +net = ["runtime", "metrics", "ticket", "dep:axum", "dep:hmac", "dep:sha2", "dep:base64", "tokio/net", "tokio/macros", "tokio/io-util"] # `world::FencedLeaseOwnership`: zone ownership over `distributed::lease`. lease = ["dep:distributed"] diff --git a/distributed_game/src/host.rs b/distributed_game/src/host.rs index a2cb7b04..45b9106d 100644 --- a/distributed_game/src/host.rs +++ b/distributed_game/src/host.rs @@ -1847,7 +1847,12 @@ impl ZoneHost { } fn tick_once(&mut self, dt: Duration) { + // wasm32-unknown-unknown (celld) has no `Instant`: measure with the + // host clock there (spike gr-p42). + #[cfg(not(target_arch = "wasm32"))] let started = std::time::Instant::now(); + #[cfg(target_arch = "wasm32")] + let started = self.core.clock.now(); self.core.tick += 1; self.core.sim_time += dt; self.core.dt = dt; @@ -1867,7 +1872,12 @@ impl ZoneHost { .rewind .record(self.core.tick, self.core.sim_time, entities); } + #[cfg(not(target_arch = "wasm32"))] self.pending.tick_durations.push(started.elapsed()); + #[cfg(target_arch = "wasm32")] + self.pending + .tick_durations + .push(self.core.clock.now().saturating_sub(started)); if self.hash_ticks { let hash = self.state_hash(); self.pending.tick_hashes.push((self.core.tick, hash)); diff --git a/distributed_game/src/lib.rs b/distributed_game/src/lib.rs index 721d48d8..828b3a40 100644 --- a/distributed_game/src/lib.rs +++ b/distributed_game/src/lib.rs @@ -83,6 +83,11 @@ pub mod rng; pub mod schema; pub mod stamp; pub mod surface; +/// Signed admission tickets (also `net::ticket`); usable without the axum +/// server, e.g. by a celld zone host on wasm32. +#[cfg(feature = "ticket")] +#[path = "net/ticket.rs"] +pub mod ticket; pub mod timers; pub mod timestep; pub mod world; diff --git a/distributed_game/src/net/mod.rs b/distributed_game/src/net/mod.rs index 637b5e6f..b94220d5 100644 --- a/distributed_game/src/net/mod.rs +++ b/distributed_game/src/net/mod.rs @@ -45,7 +45,7 @@ pub mod inbox; pub mod peer; -pub mod ticket; +pub use crate::ticket; pub mod transfer; pub mod world; diff --git a/examples/game/zone-do-spike/.gitignore b/examples/game/zone-do-spike/.gitignore new file mode 100644 index 00000000..ba946d67 --- /dev/null +++ b/examples/game/zone-do-spike/.gitignore @@ -0,0 +1,4 @@ +build/ +target/ +public/page.js +.run/ diff --git a/examples/game/zone-do-spike/Cargo.toml b/examples/game/zone-do-spike/Cargo.toml new file mode 100644 index 00000000..ac34e390 --- /dev/null +++ b/examples/game/zone-do-spike/Cargo.toml @@ -0,0 +1,27 @@ +# Spike gr-p42: one Mob Pit room as a celld Durable Object (wasm32). +# Own workspace (cdylib for wasm32-unknown-unknown); built with +# `worker-build --release` by `./run.sh`. Not a product crate. +[workspace] +members = ["."] +resolver = "2" + +[package] +name = "zone-do-spike" +version = "0.1.0" +edition = "2021" +publish = false +description = "Spike: a live Mob Pit zone as a celld Durable Object (dev only)" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +console_error_panic_hook = "0.1" +distributed_game = { path = "../../../distributed_game", default-features = false, features = ["ticket"] } +game_sim = { path = "../server/sim" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +worker = "0.8" + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/examples/game/zone-do-spike/page/main.ts b/examples/game/zone-do-spike/page/main.ts new file mode 100644 index 00000000..3522d5f0 --- /dev/null +++ b/examples/game/zone-do-spike/page/main.ts @@ -0,0 +1,60 @@ +// Spike gr-p42 debug page: one Mob Pit room on celld, drawn in 2D, with the +// node / isolate / activation running it. +import { connectRoom, ATTACK } from './room-client.ts'; + +const params = new URLSearchParams(location.search); +const room = params.get('room') ?? 'pit-1'; +const name = params.get('name') ?? `p${Math.floor(Math.random() * 1000)}`; +const rc = connectRoom(location.origin, room, name); +const canvas = document.querySelector('canvas')!; +const g = canvas.getContext('2d')!; +const overlay = document.querySelector('#overlay')!; +const keys = new Set(); +addEventListener('keydown', (e) => keys.add(e.key.toLowerCase())); +addEventListener('keyup', (e) => keys.delete(e.key.toLowerCase())); +let status = 'connecting'; +rc.welcome.then(() => (status = 'joined'), (e) => (status = `failed: ${e}`)); +rc.client.on('zoneClosed', (_z, info) => (status = `closed ${JSON.stringify(info)}`)); +setInterval(() => { + const mx = (keys.has('d') ? 127 : 0) - (keys.has('a') ? 127 : 0); + const my = (keys.has('s') ? 127 : 0) - (keys.has('w') ? 127 : 0); + if (status === 'joined') rc.input(mx, my, keys.has(' ') || keys.has('j') ? ATTACK : 0); +}, 1000 / 30); +async function placement() { + try { + const r = await fetch(`/placement?rooms=${encodeURIComponent(room)}`); + const p = await r.json(); + (document.querySelector('#placement') as HTMLElement).textContent = + `operator view: ${room} owned by ${p.rooms[room] ?? '?'}\n` + + p.nodes.map((n: any) => `${n.node}: ${n.up ? `${n.resident_cells} cells, cpu ${n.cpu_percent}%` : 'down'}`).join('\n'); + } catch {} +} +setInterval(placement, 2000); +placement(); +function frame() { + const s = canvas.width / 42; + g.fillStyle = '#14161a'; + g.fillRect(0, 0, canvas.width, canvas.height); + const P = (x: number, y: number) => [(x + 21) * s, (y + 21) * s] as const; + const me = rc.hello()?.entity; + for (const e of rc.entities()) { + const f = e.fields as Record; + if (e.kindName === 'pillar') { const [x, y] = P(f.x, f.y); g.fillStyle = '#444'; g.beginPath(); g.arc(x, y, f.r * s, 0, 7); g.fill(); } + if (e.kindName === 'mob') { const [x, y] = P(f.x, f.y); g.fillStyle = ['', '#9ad16b', '#b48cf2', '#ff6b6b'][f.tier] ?? '#9ad16b'; g.beginPath(); g.arc(x, y, (0.4 + f.tier * 0.12) * s, 0, 7); g.fill(); if (f.tele) { g.strokeStyle = '#f80'; g.beginPath(); g.arc(x, y, 2 * s * f.tele / 255, 0, 7); g.stroke(); } } + if (e.kindName === 'player') { const [x, y] = P(f.x, f.y); g.fillStyle = e.id === me ? '#4cc3ff' : '#ddd'; g.beginPath(); g.arc(x, y, 0.5 * s, 0, 7); g.fill(); g.fillStyle = '#fff'; g.fillText(`${f.name} hp${f.hp} k${f.level}`, x + 8, y - 8); } + } + const h = rc.host(); + const pit = rc.entities().find((e) => e.kindName === 'pit')?.fields as any; + overlay.textContent = [ + `room ${room} as ${name} — ${status}`, + `celld node ${h?.node ?? '?'}`, + `isolate ${h?.isolate ?? '?'} (${h?.isolate_rooms ?? '?'} rooms seen by this isolate)`, + `activation ${h?.activation ?? '?'} #${h?.activations ?? '?'} ${h?.restored ? 'restored from storage' : 'fresh'} wake ${h?.wake_ms ?? '?'} ms`, + `tick ${h?.hz ?? '?'} Hz late ${h?.late_ms?.toFixed(2)} ms (max/s ${h?.late_max_ms?.toFixed(1)})`, + `room kills ${pit?.kills ?? '?'} lifetime ticks ${pit?.ticks ?? '?'}`, + `rtt ${rc.rtt()?.toFixed(1) ?? '?'} ms (via ${location.host})`, + `WASD move, space/J attack` + ].join('\n'); + requestAnimationFrame(frame); +} +frame(); diff --git a/examples/game/zone-do-spike/page/room-client.ts b/examples/game/zone-do-spike/page/room-client.ts new file mode 100644 index 00000000..144e081a --- /dev/null +++ b/examples/game/zone-do-spike/page/room-client.ts @@ -0,0 +1,61 @@ +// Spike gr-p42: a Mob Pit room client over the framework's generic +// GameClient (no generated code, no prediction). Shared by the debug page +// and the headless test (Node 22+ has a global WebSocket). +import { GameClient, decodePayload, encodePayload, type PayloadLayout, type RenderedEntity } from '../../../../js/src/game/index.ts'; + +const INPUT: PayloadLayout = [['mx', { type: 'i8' }], ['my', { type: 'i8' }], ['buttons', { type: 'u8' }]]; +const HELLO: PayloadLayout = [['entity', { type: 'u32' }], ['half', { type: 'f32' }], ['boot', { type: 'short_str' }], ['attackRange', { type: 'f32' }], ['attackArcCos', { type: 'f32' }]]; +const KILL: PayloadLayout = [['mob', { type: 'u32' }], ['tier', { type: 'u8' }], ['killer', { type: 'u32' }], ['killerName', { type: 'short_str' }], ['kills', { type: 'u32' }]]; +export const ATTACK = 2; + +export type Host = { node: string; isolate: string; activation: string; activations: number; restored: boolean; wakeMs: number; hz: number; lateMs: number; lateMaxMs: number; workUs: number; isolateRooms: number }; + +export type RoomClient = { + client: GameClient; + welcome: Promise; + hello: () => { entity: number; boot: string } | undefined; + host: () => Host | undefined; + entities: () => RenderedEntity[]; + me: () => RenderedEntity | undefined; + kills: { mob: number; killer: number; killerName: string; kills: number }[]; + input: (mx: number, my: number, buttons: number) => void; + rtt: () => number | undefined; + close: () => void; +}; + +export function connectRoom(base: string, room: string, name: string): RoomClient { + const ws = base.replace(/^http/, 'ws'); + let hello: { entity: number; boot: string } | undefined; + const kills: RoomClient['kills'] = []; + const client = new GameClient({ + url: (zone) => `${ws}/rooms/${encodeURIComponent(zone)}`, + ticket: async ({ zoneId }) => { + const r = await fetch(`${base}/ticket?room=${encodeURIComponent(zoneId)}&name=${encodeURIComponent(name)}`); + return ((await r.json()) as { ticket: string }).ticket; + }, + pingIntervalMs: 500, + reconnect: false + }); + client.on('event', (_zone, e) => { + if (e.typeId === 1) hello = decodePayload(HELLO, e.payload) as never; + if (e.typeId === 3) kills.push(decodePayload(KILL, e.payload) as never); + }); + const welcome = client.connect(room); + const entities = () => client.render(); + const host = (): Host | undefined => entities().find((e) => e.kindName === 'host')?.fields as never; + return { + client, + welcome, + hello: () => hello, + host, + entities, + me: () => entities().find((e) => e.kindName === 'player' && hello && e.id === hello.entity), + kills, + input: (mx, my, buttons) => client.sendRawInput(encodePayload(INPUT, { mx, my, buttons })), + rtt: () => { + const z = client.zone(room) as unknown as { connection?: { clock?: { rtt?: number } } } | undefined; + return z?.connection?.clock?.rtt; + }, + close: () => client.close() + }; +} diff --git a/examples/game/zone-do-spike/public/index.html b/examples/game/zone-do-spike/public/index.html new file mode 100644 index 00000000..44c43715 --- /dev/null +++ b/examples/game/zone-do-spike/public/index.html @@ -0,0 +1,19 @@ + + + + +Pit on celld + + + + +

+ + + diff --git a/examples/game/zone-do-spike/run.sh b/examples/game/zone-do-spike/run.sh new file mode 100755 index 00000000..3db39e0c --- /dev/null +++ b/examples/game/zone-do-spike/run.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Spike gr-p42: Mob Pit rooms as celld Durable Objects on a local +# multi-node celld fleet (production mode, one shared S3 bucket). +# +# ./run.sh up [N] S3 container + bucket, build, deploy, N nodes (default 3) +# ./run.sh deploy rebuild and publish a new version (nodes adopt it live) +# ./run.sh nodes [N] start N nodes ./run.sh kill SIGKILL one node +# ./run.sh status per-node owned/resident cells (operator view) +# ./run.sh down stop nodes, remove the S3 container +# +# Nodes: node-a/b/c, public 127.0.0.1:18101-18103 (open any of them in a +# browser: /?room=pit-1&name=ana), operator 127.0.0.1:18201-18203. +# Needs: CELLD (celld >= 0.6.2), worker-build, esbuild (CELLD_ESBUILD), docker. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STATE="${SPIKE_STATE:-$HERE/.run}" +CELLD="${CELLD:-celld}" +export CELLD_ESBUILD="${CELLD_ESBUILD:-$HERE/../client/node_modules/.bin/esbuild}" +export AWS_ACCESS_KEY_ID=spikeadmin AWS_SECRET_ACCESS_KEY=spikesecret123 AWS_REGION=us-east-1 +S3_PORT="${S3_PORT:-19400}" +export S3_ENDPOINT="http://127.0.0.1:$S3_PORT" +BUCKET="s3://zone-spike/zone" +NAMES=(a b c d) +mkdir -p "$STATE" + +s3_up() { + S3_PORT="$S3_PORT" docker compose -p zone-do-spike -f "$HERE/tools/s3-compose.yaml" up -d >/dev/null 2>&1 + for _ in $(seq 1 30); do curl -s -o /dev/null "$S3_ENDPOINT/" && break; sleep 1; done + aws --endpoint-url "$S3_ENDPOINT" s3 mb s3://zone-spike >/dev/null 2>&1 || true +} + +build() { + (cd "$HERE" && worker-build --release >/dev/null) + "$CELLD_ESBUILD" "$HERE/page/main.ts" --bundle --format=esm --target=es2022 \ + --outfile="$HERE/public/page.js" --log-level=warning +} + +deploy() { + build + (cd "$HERE" && "$CELLD" deploy . --bucket "$BUCKET" 2>&1 | grep -E "Version ID|rror") +} + +nodes() { + local n="${1:-3}" + for i in $(seq 0 $((n - 1))); do + local nm=${NAMES[$i]} dir="$STATE/node-${NAMES[$i]}" + mkdir -p "$dir" + [[ -f "$dir/pid" ]] && kill -0 "$(cat "$dir/pid")" 2>/dev/null && continue + CELLD_NODE="node-$nm" CELLD_WATCH="$dir/watch" RUST_LOG="${RUST_LOG:-warn}" \ + CELLD_IDLE_EVICT_S="${CELLD_IDLE_EVICT_S:-10}" \ + nohup "$CELLD" --bucket "$BUCKET" --listen "127.0.0.1:$((18101 + i))" \ + --internal-listen "127.0.0.1:$((18201 + i))" --advertise "127.0.0.1:$((18201 + i))" \ + >"$dir/celld.log" 2>&1 & + echo $! >"$dir/pid" + echo "node-$nm: http://127.0.0.1:$((18101 + i))/ (operator :$((18201 + i)), pid $!)" + done + for i in $(seq 0 $((n - 1))); do + for _ in $(seq 1 60); do curl -fs "http://127.0.0.1:$((18101 + i))/ticket" >/dev/null && break; sleep 0.5; done + done +} + +status() { + for i in 0 1 2 3; do + curl -s -m 2 "http://127.0.0.1:$((18201 + i))/state" 2>/dev/null | python3 -c ' +import json,sys +d=json.load(sys.stdin); l=d["node_load"] +print(sys.argv[1], "owned", d["owned_cells"], "resident", l["resident_cells"], "cpu%", l["cpu_percent_x100"]/100, "rebalanced", d["rebalanced"], "version", d["deployment"]["version"])' "node-${NAMES[$i]}" 2>/dev/null || true + done +} + +down() { + for nm in "${NAMES[@]}"; do + local f="$STATE/node-$nm/pid" + [[ -f $f ]] && { kill "$(cat "$f")" 2>/dev/null || true; rm -f "$f"; } + done + docker compose -p zone-do-spike -f "$HERE/tools/s3-compose.yaml" down -v >/dev/null 2>&1 || true +} + +case "${1:-}" in +up) s3_up; deploy; nodes "${2:-3}" ;; +deploy) deploy ;; +nodes) nodes "${2:-3}" ;; +kill) kill -9 "$(cat "$STATE/node-$2/pid")"; rm -f "$STATE/node-$2/pid"; echo "killed node-$2" ;; +status) status ;; +down) down ;; +*) sed -n '2,13p' "$0"; exit 1 ;; +esac diff --git a/examples/game/zone-do-spike/src/lib.rs b/examples/game/zone-do-spike/src/lib.rs new file mode 100644 index 00000000..8ae06578 --- /dev/null +++ b/examples/game/zone-do-spike/src/lib.rs @@ -0,0 +1,755 @@ +//! Spike gr-p42: a live Mob Pit room as a celld Durable Object. +//! +//! - `GET /ticket?room=&name=` (dev only): a signed join ticket, the same +//! HS256 format the native zone server admits (`distributed_game::ticket`). +//! - `GET /rooms/{room}` (WebSocket upgrade): the room object named `room`. +//! The client speaks the existing `distributed_game` wire protocol: Hello +//! with the ticket, then Input / Ping / Leave; the room answers Welcome, +//! Delta, Event, Pong. +//! - `GET /rooms/{room}/stats`: tick timing numbers of the room (JSON). +//! - `GET /placement?rooms=a,b`: which node owns each room (operator view). +//! - everything else: the static debug page (`public/`). +//! +//! The room (`PitRoom`) runs a `ZoneHost` in the object's memory. It +//! ticks with a `setTimeout` loop while any player is in it, writes its +//! hibernation snapshot to object storage every few seconds and when the +//! last player leaves, then stops; celld may then evict it. The next join +//! reactivates it on whichever node owns it and restores the snapshot. +//! +//! celld does not tell application code which node runs it, so the room +//! asks every node's operator listener (`NODES`) whether it holds the room. + +mod pit; + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::rc::Rc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use distributed_game::protocol::{error, ClientMessage, ServerMessage, PROTOCOL_VERSION}; +use distributed_game::ticket::TicketKey; +use distributed_game::{Clock, HostConfig, Outbound, Role, TicketClaims, ZoneHost}; +use pit::Pit; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use worker::*; + +const SNAPSHOT_KEY: &str = "pit-snapshot"; +const PERSIST_EVERY_MS: f64 = 5_000.0; + +// ------------------------------------------------------------------ time + +fn performance_now() -> f64 { + let global = js_sys::global(); + js_sys::Reflect::get(&global, &"performance".into()) + .ok() + .and_then(|p| { + let now = js_sys::Reflect::get(&p, &"now".into()).ok()?; + let now: js_sys::Function = now.into(); + now.call0(&p).ok()?.as_f64() + }) + .unwrap_or_else(js_sys::Date::now) +} + +/// Monotonic clock over `Date.now()` (`Instant` panics on wasm32). +struct JsClock { + origin_ms: f64, + last_us: AtomicU64, +} + +impl JsClock { + fn new() -> Self { + Self { + origin_ms: js_sys::Date::now(), + last_us: AtomicU64::new(0), + } + } +} + +impl Clock for JsClock { + fn now(&self) -> Duration { + let us = ((js_sys::Date::now() - self.origin_ms).max(0.0) * 1000.0) as u64; + let us = self.last_us.fetch_max(us, Ordering::Relaxed).max(us); + Duration::from_micros(us) + } + + fn unix_ms(&self) -> u64 { + self.origin_ms as u64 + self.now().as_millis() as u64 + } +} + +fn random_id() -> String { + format!( + "{:08x}", + (js_sys::Math::random() * f64::from(u32::MAX)) as u32 + ) +} + +thread_local! { + /// One id per V8 isolate (module instance). + static ISOLATE: String = random_id(); + /// Rooms alive in this isolate. + static ISOLATE_ROOMS: RefCell> = RefCell::new(HashSet::new()); +} + +// ------------------------------------------------------------------ stats + +/// Loop timing over the life of an activation. +#[derive(Default)] +struct TickStats { + /// How late each wake-up was against its deadline (ms). + late: VecDeque, + /// Interval between consecutive loop iterations (ms). + interval: VecDeque, + /// Work per iteration (ms; advance + sends). + work: VecDeque, + /// Fixed steps dropped by the catch-up cap. + dropped_ticks: u64, + /// Iterations that ran more than one fixed step (catch-up). + catch_up: u64, + iterations: u64, + last_wake: Option, + late_ema: f64, + work_ema: f64, + late_max_window: f64, + window: u32, + late_max: f64, +} + +const STAT_WINDOW: usize = 20_000; + +fn push(q: &mut VecDeque, v: f64) { + if q.len() == STAT_WINDOW { + q.pop_front(); + } + q.push_back(v); +} + +fn pct(q: &VecDeque, p: f64) -> f64 { + if q.is_empty() { + return 0.0; + } + let mut v: Vec = q.iter().copied().collect(); + v.sort_by(|a, b| a.total_cmp(b)); + v[((v.len() - 1) as f64 * p).round() as usize] +} + +// ------------------------------------------------------------------ room + +#[derive(Serialize, Deserialize)] +struct Attachment { + client: u32, + activation: String, +} + +struct Room { + name: String, + id_hex: String, + activation: String, + constructed_ms: f64, + host: Option>, + sockets: HashMap, + ticking: bool, + hz: u32, + key: TicketKey, + nonces: HashSet, + stats: TickStats, + first_tick_ms: Option, + restored: bool, + node: String, + welcome_ms: Vec, +} + +impl Room { + fn flush(&mut self) { + let Some(host) = self.host.as_mut() else { + return; + }; + for out in host.drain_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")); + } + } + } + } + } + + fn occupied(&self) -> bool { + self.host + .as_ref() + .is_some_and(|h| h.player_count() > 0 || h.client_count() > 0) + } + + fn snapshot(&self) -> Option { + let state = self.host.as_ref()?.hibernate_state()?; + String::from_utf8(state).ok() + } + + fn sync_host_info(&mut self) { + let isolate_rooms = ISOLATE_ROOMS.with(|r| r.borrow().len()) as u16; + let (late, late_max, work) = ( + self.stats.late_ema, + self.stats.late_max, + self.stats.work_ema, + ); + let wake_ms = self + .first_tick_ms + .map(|t| (t - self.constructed_ms).max(0.0) as u32) + .unwrap_or(0); + let (node, restored, hz) = (self.node.clone(), self.restored, self.hz as u8); + if let Some(host) = self.host.as_mut() { + let zone = host.zone_mut(); + let activations = zone.activations; + let info = &mut zone.host; + info.node = node; + info.restored = restored; + info.activations = activations; + info.hz = hz; + info.late_ms = late as f32; + info.late_max_ms = late_max as f32; + info.work_us = (work * 1000.0) as u32; + info.wake_ms = wake_ms; + info.isolate_rooms = isolate_rooms; + } + } +} + +#[durable_object] +pub struct PitRoom { + state: State, + env: Env, + room: Rc>, +} + +impl DurableObject for PitRoom { + fn new(state: State, env: Env) -> Self { + console_error_panic_hook::set_once(); + let constructed_ms = performance_now(); + let id_hex = state.id().to_string(); + let name = state.id().name().unwrap_or_default(); + let hz = env + .var("TICK_HZ") + .ok() + .and_then(|v| v.to_string().parse().ok()) + .unwrap_or(30); + let secret = env + .var("TICKET_SECRET") + .map(|v| v.to_string()) + .unwrap_or_default(); + let room = Room { + name, + id_hex, + activation: random_id(), + constructed_ms, + host: None, + sockets: HashMap::new(), + ticking: false, + hz, + key: TicketKey::new(secret.into_bytes()), + nonces: HashSet::new(), + stats: TickStats::default(), + first_tick_ms: None, + restored: false, + node: "?".into(), + welcome_ms: Vec::new(), + }; + Self { + state, + env, + room: Rc::new(RefCell::new(room)), + } + } + + async fn fetch(&self, req: Request) -> Result { + let url = req.url()?; + if let Some(name) = req.headers().get("x-room")? { + let mut room = self.room.borrow_mut(); + if room.name.is_empty() { + room.name = name; + } + } + self.ensure_host().await?; + if url.path().ends_with("/stats") { + if url.query_pairs().any(|(k, _)| k == "reset") { + self.room.borrow_mut().stats = TickStats::default(); + } + return Response::from_json(&self.stats_json()); + } + if req.headers().get("upgrade")?.as_deref() != Some("websocket") { + return Response::error("expected a WebSocket upgrade", 426); + } + let pair = WebSocketPair::new()?; + self.state.accept_web_socket(&pair.server); + Response::from_websocket(pair.client) + } + + async fn websocket_message( + &self, + ws: WebSocket, + message: WebSocketIncomingMessage, + ) -> Result<()> { + let WebSocketIncomingMessage::Binary(bytes) = message else { + let _ = ws.close(Some(1003), Some("binary frames only")); + return Ok(()); + }; + self.ensure_host().await?; + let msg = match ClientMessage::decode(&bytes) { + Ok(m) => m, + Err(_) => { + reject(&ws, error::BAD_MESSAGE, "bad message"); + return Ok(()); + } + }; + let attached: Option = ws.deserialize_attachment().ok().flatten(); + let activation = self.room.borrow().activation.clone(); + let client = match attached { + Some(a) if a.activation == activation => Some(a.client), + // The socket outlived the activation that admitted it (the room + // was evicted and woke up again): its player is gone. + Some(_) => { + let _ = ws.close(Some(1012), Some("room restarted; rejoin")); + return Ok(()); + } + None => None, + }; + match (client, msg) { + (None, ClientMessage::Hello(hello)) => { + let t0 = performance_now(); + if hello.protocol_version != PROTOCOL_VERSION { + reject(&ws, error::VERSION_MISMATCH, "protocol version"); + return Ok(()); + } + let joined = { + let mut room = self.room.borrow_mut(); + let now = (js_sys::Date::now() / 1000.0) as u64; + let claims = match room.key.verify(&hello.ticket, now) { + Ok(c) => c, + Err(e) => { + drop(room); + reject(&ws, error::UNAUTHORIZED, &format!("{e:?}")); + return Ok(()); + } + }; + if claims.zone != room.name || !room.nonces.insert(claims.nonce.clone()) { + drop(room); + reject(&ws, error::UNAUTHORIZED, "wrong room or replayed ticket"); + return Ok(()); + } + room.sync_host_info(); + let host = room.host.as_mut().expect("host loaded"); + match host.join(&claims, hello.capabilities, &hello.content_version) { + Ok(joined) => { + let client = joined.client.0; + ws.serialize_attachment(Attachment { + client, + activation: activation.clone(), + })?; + room.sockets.insert(client, ws.clone()); + room.flush(); + let elapsed = performance_now() - t0; + room.welcome_ms.push(elapsed); + Ok(()) + } + Err(e) => Err(e), + } + }; + match joined { + Ok(()) => self.start_loop(), + Err(e) => reject(&ws, e.code, &e.message), + } + } + ( + Some(client), + ClientMessage::Input { + seq, + client_tick, + payload, + }, + ) => { + let mut room = self.room.borrow_mut(); + if let Some(host) = room.host.as_mut() { + host.push_input( + distributed_game::ClientId(client), + seq, + client_tick, + &payload, + ); + } + } + (_, ClientMessage::Ping { client_time }) => { + let room = self.room.borrow(); + let server_time = room.host.as_ref().map_or(0.0, |h| h.server_time_ms()); + let pong = ServerMessage::Pong { + client_time, + server_time, + } + .encode(&()) + .expect("pong encodes"); + let _ = ws.send_with_bytes(pong); + } + (Some(client), ClientMessage::Leave) => { + let mut room = self.room.borrow_mut(); + room.sockets.remove(&client); + if let Some(host) = room.host.as_mut() { + host.disconnect(distributed_game::ClientId(client), true); + } + room.flush(); + let _ = ws.close(Some(1000), Some("left")); + } + _ => reject(&ws, error::BAD_MESSAGE, "unexpected message"), + } + Ok(()) + } + + async fn websocket_close( + &self, + ws: WebSocket, + _code: usize, + _reason: String, + _was_clean: bool, + ) -> Result<()> { + self.drop_socket(&ws); + Ok(()) + } + + async fn websocket_error(&self, ws: WebSocket, _error: Error) -> Result<()> { + self.drop_socket(&ws); + Ok(()) + } +} + +fn reject(ws: &WebSocket, code: u32, message: &str) { + if let Ok(frame) = (ServerMessage::Error { + code, + message: message.into(), + }) + .encode(&()) + { + let _ = ws.send_with_bytes(frame); + } + let _ = ws.close(Some(1008), Some(message)); +} + +impl PitRoom { + fn drop_socket(&self, ws: &WebSocket) { + let attached: Option = ws.deserialize_attachment().ok().flatten(); + let mut room = self.room.borrow_mut(); + if let Some(a) = attached.filter(|a| a.activation == room.activation) { + room.sockets.remove(&a.client); + if let Some(host) = room.host.as_mut() { + host.disconnect(distributed_game::ClientId(a.client), false); + } + } + } + + /// Create the zone host on first use: restore the stored snapshot and + /// find out which node this is. + async fn ensure_host(&self) -> Result<()> { + if self.room.borrow().host.is_some() { + return Ok(()); + } + let stored: Option = self.state.storage().get(SNAPSHOT_KEY).await?; + let mut room = self.room.borrow_mut(); + if room.host.is_some() { + return Ok(()); + } + // `name@20` runs the room at 20 Hz (measurements). + if let Some(hz) = room + .name + .split_once('@') + .and_then(|(_, hz)| hz.parse().ok()) + { + room.hz = hz; + } + let config = HostConfig { + tick_rate: room.hz, + patch_rate: room.hz.min(20), + // The Durable Object stops ticking when empty instead. + idle_tick_rate: None, + boot_epoch: (js_sys::Date::now() * 1000.0) as u64, + reconnect_grace: Duration::from_secs(5), + ..HostConfig::default() + }; + let name = room.name.clone(); + let mut host = ZoneHost::::new(name.clone(), config, Arc::new(JsClock::new()), ()) + .with_token_source(|| format!("{}{}", random_id(), random_id())); + if let Some(state) = stored { + host.restore(state.as_bytes(), ""); + room.restored = true; + } + { + let activation = room.activation.clone(); + let info = &mut host.zone_mut().host; + info.activation = activation; + info.isolate = ISOLATE.with(|i| i.clone()); + } + room.host = Some(host); + ISOLATE_ROOMS.with(|r| r.borrow_mut().insert(name)); + drop(room); + self.find_node(); + Ok(()) + } + + /// Ask each node's operator listener whether it holds this room. + fn find_node(&self) { + let nodes = self + .env + .var("NODES") + .map(|v| v.to_string()) + .unwrap_or_default(); + let room = self.room.clone(); + let id_hex = room.borrow().id_hex.clone(); + wasm_bindgen_futures::spawn_local(async move { + for entry in nodes.split(',').filter(|e| !e.is_empty()) { + let Some((node, addr)) = entry.split_once('=') else { + continue; + }; + let Ok(mut resp) = Fetch::Url( + format!("http://{addr}/state") + .parse() + .expect("operator url"), + ) + .send() + .await + else { + continue; + }; + let Ok(state) = resp.json::().await else { + continue; + }; + let mine = state["deployment"]["cells"] + .as_object() + .is_some_and(|cells| cells.keys().any(|k| k.ends_with(&id_hex))); + if mine { + let mut room = room.borrow_mut(); + room.node = node.to_string(); + room.sync_host_info(); + return; + } + } + }); + } + + fn start_loop(&self) { + { + let mut room = self.room.borrow_mut(); + if room.ticking { + return; + } + room.ticking = true; + } + let room = self.room.clone(); + let storage = self.state.storage(); + wasm_bindgen_futures::spawn_local(async move { + let step_ms = 1000.0 / f64::from(room.borrow().hz); + let start = performance_now(); + let mut k: u64 = 1; + let mut last_persist = start; + loop { + let deadline = start + k as f64 * step_ms; + let wait = (deadline - performance_now()).max(0.0); + Delay::from(Duration::from_micros((wait * 1000.0) as u64)).await; + let woke = performance_now(); + // Skip deadlines we already missed (the fixed timestep + // catches up with several steps in one advance). + k = (((woke - start) / step_ms).floor() as u64 + 1).max(k + 1); + let (stop, snapshot) = { + let mut room = room.borrow_mut(); + let late = (woke - deadline).max(0.0); + let interval = room.stats.last_wake.map(|l| woke - l); + let report = room.host.as_mut().map(|h| h.advance()); + if room.first_tick_ms.is_none() { + room.first_tick_ms = Some(woke); + } + room.flush(); + let work = performance_now() - woke; + let s = &mut room.stats; + s.iterations += 1; + s.last_wake = Some(woke); + push(&mut s.late, late); + if let Some(i) = interval { + push(&mut s.interval, i); + } + push(&mut s.work, work); + if let Some(r) = &report { + s.dropped_ticks += u64::from(r.dropped_ticks); + if r.ticks > 1 { + s.catch_up += 1; + } + } + s.late_ema = if s.iterations == 1 { + late + } else { + s.late_ema * 0.95 + late * 0.05 + }; + s.work_ema = if s.iterations == 1 { + work + } else { + s.work_ema * 0.95 + work * 0.05 + }; + s.late_max_window = s.late_max_window.max(late); + s.window += 1; + if s.window >= room.hz { + let s = &mut room.stats; + s.late_max = s.late_max_window; + s.late_max_window = 0.0; + s.window = 0; + } + room.sync_host_info(); + let stop = !room.occupied(); + let persist = stop || woke - last_persist >= PERSIST_EVERY_MS; + if stop { + room.ticking = false; + } + (stop, persist.then(|| room.snapshot()).flatten()) + }; + if let Some(snapshot) = snapshot { + last_persist = woke; + if let Err(e) = storage.put(SNAPSHOT_KEY, snapshot).await { + console_error!("persist failed: {e}"); + } + } + if stop { + return; + } + } + }); + } + + fn stats_json(&self) -> serde_json::Value { + let room = self.room.borrow(); + let s = &room.stats; + let host = room.host.as_ref(); + json!({ + "room": room.name, + "node": room.node, + "isolate": ISOLATE.with(|i| i.clone()), + "activation": room.activation, + "restored": room.restored, + "hz": room.hz, + "ticking": room.ticking, + "players": host.map_or(0, |h| h.player_count()), + "tick": host.map_or(0, |h| h.tick()), + "lifetime_ticks": host.map_or(0, |h| h.zone().ticks()), + "kills": host.map_or(0, |h| h.zone().kills), + "activations": host.map_or(0, |h| h.zone().activations), + "isolate_rooms": ISOLATE_ROOMS.with(|r| r.borrow().len()), + "wake_ms": room.first_tick_ms.map(|t| t - room.constructed_ms), + "welcome_ms": room.welcome_ms, + "iterations": s.iterations, + "dropped_ticks": s.dropped_ticks, + "catch_up_iterations": s.catch_up, + "late_ms": {"p50": pct(&s.late, 0.5), "p99": pct(&s.late, 0.99), "max": pct(&s.late, 1.0)}, + "interval_ms": {"p50": pct(&s.interval, 0.5), "p99": pct(&s.interval, 0.99), "max": pct(&s.interval, 1.0)}, + "work_ms": {"p50": pct(&s.work, 0.5), "p99": pct(&s.work, 0.99), "max": pct(&s.work, 1.0)}, + }) + } +} + +// ------------------------------------------------------------------ worker + +fn ticket(env: &Env, room: &str, name: &str) -> Result { + let secret = env.var("TICKET_SECRET")?.to_string(); + let key = TicketKey::new(secret.into_bytes()); + let exp = (js_sys::Date::now() / 1000.0) as u64 + 300; + let principal = format!("guest:{name}"); + let claims = TicketClaims::new( + principal, + room, + exp, + random_id() + &random_id(), + Role::Player, + ) + .with_name(name); + Ok(key.sign(&claims)) +} + +async fn placement(env: &Env, rooms: &str) -> Result { + let ns = env.durable_object("PIT_ROOM")?; + let mut ids = Vec::new(); + for room in rooms.split(',').filter(|r| !r.is_empty()) { + ids.push((room.to_string(), ns.id_from_name(room)?.to_string())); + } + let nodes = env.var("NODES")?.to_string(); + let mut out = serde_json::Map::new(); + let mut node_rows = Vec::new(); + for entry in nodes.split(',').filter(|e| !e.is_empty()) { + let Some((node, addr)) = entry.split_once('=') else { + continue; + }; + let state = match Fetch::Url(format!("http://{addr}/state").parse()?) + .send() + .await + { + Ok(mut r) => r.json::().await.ok(), + Err(_) => None, + }; + let Some(state) = state else { + node_rows.push(json!({"node": node, "up": false})); + continue; + }; + let cells = state["deployment"]["cells"] + .as_object() + .cloned() + .unwrap_or_default(); + for (room, id) in &ids { + if cells.keys().any(|k| k.ends_with(id.as_str())) { + out.insert(room.clone(), json!(node)); + } + } + node_rows.push(json!({ + "node": node, + "up": true, + "resident_cells": state["node_load"]["resident_cells"], + "owned_cells": state["owned_cells"], + "cpu_percent": state["node_load"]["cpu_percent_x100"].as_f64().map(|c| c / 100.0), + "rebalanced": state["rebalanced"], + })); + } + Response::from_json(&json!({"rooms": out, "nodes": node_rows})) +} + +#[event(fetch)] +async fn main(req: Request, env: Env, _ctx: Context) -> Result { + console_error_panic_hook::set_once(); + let url = req.url()?; + let path = url.path().to_string(); + let query: HashMap = url.query_pairs().into_owned().collect(); + if path == "/ticket" { + let room = query.get("room").cloned().unwrap_or_else(|| "pit-1".into()); + let name = query.get("name").cloned().unwrap_or_else(|| "guest".into()); + return Response::from_json(&json!({"ticket": ticket(&env, &room, &name)?})); + } + if path == "/placement" { + return placement(&env, query.get("rooms").map_or("", |s| s.as_str())).await; + } + if let Some(rest) = path.strip_prefix("/rooms/") { + let raw = rest.split('/').next().unwrap_or_default(); + let room: String = js_sys::decode_uri_component(raw) + .map(String::from) + .unwrap_or_else(|_| raw.to_string()); + if room.is_empty() { + return Response::error("room required", 400); + } + let ns = env.durable_object("PIT_ROOM")?; + let stub = ns.id_from_name(&room)?.get_stub()?; + // Name the room for the object (its id has no name on some paths). + let headers = req.headers().clone(); + headers.set("x-room", &room)?; + let mut init = RequestInit::new(); + init.with_method(req.method()).with_headers(headers); + let forwarded = Request::new_with_init(req.url()?.as_str(), &init)?; + return stub.fetch_with_request(forwarded).await; + } + let assets = env.assets("ASSETS")?; + assets.fetch_request(req).await +} diff --git a/examples/game/zone-do-spike/src/pit.rs b/examples/game/zone-do-spike/src/pit.rs new file mode 100644 index 00000000..b7ae1ac1 --- /dev/null +++ b/examples/game/zone-do-spike/src/pit.rs @@ -0,0 +1,859 @@ +//! Mob Pit, trimmed for the spike: the same players, mobs, pillars, melee +//! combat and the same replicated kinds/ids as +//! `examples/game/server/src/mobpit/zone.rs` (copied, not shared: that zone +//! lives in the native `game_demos` crate next to tokio, reqwest and the +//! Crawler fact sink, so it does not build for wasm32 as is). +//! +//! Left out: kill facts / Crawler XP settlement, leaderboard, profiles. +//! Added: the global `host` kind (which celld node, isolate and activation +//! run the room, tick numbers) and a hibernation snapshot (mobs, kill +//! counts) that the Durable Object keeps in its storage. + +use std::collections::BTreeMap; + +use distributed_game::{ + schema, ConsumePolicy, Ctx, Entities, Entity, EntityId, EntitySchema, GameEvent, GameInput, + LeaveReason, ObserverId, PlayerId, Project, Replicated, View, Zone, +}; +use game_sim::arena::{self as movement, MoveState, PILLARS, PLAYER_RADIUS}; +pub use game_sim::mobpit::{pit_step, PitInput}; +use serde::{Deserialize, Serialize}; + +/// Entity id of the zone's HUD view (as in Mob Pit). +pub const PIT_ENTITY: EntityId = EntityId(900); +/// Entity id of the host view (spike only). +pub const HOST_ENTITY: EntityId = EntityId(901); + +/// A player (Mob Pit's `player` kind, id 1). +#[derive(Debug, Clone, PartialEq, Replicated)] +#[replicated(kind = "player", id = 1, priority = 2.0, of = Player)] +pub struct PlayerView { + #[field_type(movement::POS)] + #[correct(ease = 150)] + #[from(state.x)] + pub x: f32, + #[field_type(movement::POS)] + #[correct(ease = 150)] + #[from(state.y)] + pub y: f32, + #[field_type(movement::VEL)] + #[correct(snap)] + #[from(state.vx)] + pub vx: f32, + #[field_type(movement::VEL)] + #[correct(snap)] + #[from(state.vy)] + pub vy: f32, + #[correct(snap)] + #[from(state.dash)] + pub dash: u8, + #[correct(snap)] + #[from(state.cd)] + pub cd: u8, + #[priority(0.2)] + #[correct(replace)] + #[from] + pub name: String, + #[correct(replace)] + pub hp: u8, + #[angle(bits = 8)] + #[correct(replace)] + #[from] + pub face: f32, + #[correct(replace)] + pub swing: u8, + /// Kills by this player in this room (persisted with the room). + #[correct(replace)] + pub level: u16, + #[correct(replace)] + pub dead: bool, +} + +/// A mob (Mob Pit's `mob` kind, id 2). +#[derive(Debug, Clone, PartialEq, Replicated)] +#[replicated(kind = "mob", id = 2, of = Mob)] +pub struct MobView { + #[quant(min = -movement::HALF, max = movement::HALF, bits = 14)] + #[from] + pub x: f32, + #[quant(min = -movement::HALF, max = movement::HALF, bits = 14)] + #[from] + pub y: f32, + #[angle(bits = 8)] + #[from] + pub face: f32, + #[from] + pub tier: u8, + pub hp: u8, + pub state: u8, + pub tele: u8, +} + +/// A pillar (the Arena's `pillar` kind, id 3). +#[derive(Debug, Clone, PartialEq, Replicated)] +#[replicated(kind = "pillar", id = 3, global, of = Pillar)] +pub struct PillarView { + #[from] + pub x: f32, + #[from] + pub y: f32, + #[from] + pub r: f32, +} + +/// Room numbers (global). +#[derive(Debug, Clone, PartialEq, Replicated)] +#[replicated(kind = "pit", id = 4, global, of = Pit)] +pub struct PitView { + /// Kills in this room since it was first created (persisted). + pub kills: u32, + /// Ticks this room has simulated across all activations (persisted). + pub ticks: u32, +} + +/// Where the room runs (global, spike only): set by the Durable Object. +#[derive(Debug, Clone, PartialEq, Replicated)] +#[replicated(kind = "host", id = 5, global, of = HostInfo)] +pub struct HostView { + /// celld node id (`CELLD_NODE`), found through the operator listeners. + #[from] + pub node: String, + /// Random id of the V8 isolate (module instance) running the room. + #[from] + pub isolate: String, + /// Random id of this activation of the object (new on every wake). + #[from] + pub activation: String, + /// Activations of this room so far (persisted). + #[from] + pub activations: u32, + /// The room was restored from storage on this activation. + #[from] + pub restored: bool, + /// Cold start: object constructor to first tick (ms). + #[from] + pub wake_ms: u32, + /// Tick rate (Hz). + #[from] + pub hz: u8, + /// Loop wake lateness EMA (ms; how late the timer fired). + #[from] + pub late_ms: f32, + /// Max lateness over the last second (ms). + #[from] + pub late_max_ms: f32, + /// Work per loop iteration EMA (µs: advance + sends). + #[from] + pub work_us: u32, + /// Rooms resident in the same isolate. + #[from] + pub isolate_rooms: u16, +} + +/// A static pillar (the Arena's floor). +pub struct Pillar { + id: EntityId, + pub x: f32, + pub y: f32, + pub r: f32, +} + +impl Pillar { + fn all() -> Vec { + 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 + } +} + +/// The host info the Durable Object keeps current. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct HostInfo { + pub node: String, + pub isolate: String, + pub activation: String, + pub activations: u32, + pub restored: bool, + pub wake_ms: u32, + pub hz: u8, + pub late_ms: f32, + pub late_max_ms: f32, + pub work_us: u32, + pub isolate_rooms: u16, +} + +impl Entity for HostInfo { + fn entity_id(&self) -> EntityId { + HOST_ENTITY + } +} + +/// Mob Pit's events (ids as in Mob Pit; `Kill` trimmed). +#[derive(Debug, Clone, PartialEq, GameEvent)] +pub enum PitEvent { + #[game_event(id = 1)] + Hello { + entity: u32, + half: f32, + boot: String, + attack_range: f32, + attack_arc_cos: f32, + }, + #[game_event(id = 3)] + Kill { + mob: u32, + tier: u8, + killer: u32, + killer_name: String, + /// The killer's kills in this room. + kills: u32, + }, + #[game_event(id = 6)] + Died { respawn_ms: u32 }, +} + +pub const ATTACK_RANGE: f32 = 2.2; +pub const ATTACK_ARC_COS: f32 = 0.5; +pub const ATTACK_DAMAGE: i32 = 10; +pub const SWING_COOLDOWN: u8 = 13; +const SWING_FRAMES: u8 = 6; +pub const PLAYER_HP: i32 = 100; +pub const MOBS: usize = 12; +const MAX_CREDITS: u8 = 4; +const VIEW_RADIUS: f32 = 40.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; +const PLAYER_RESPAWN_TICKS: u64 = 90; + +fn tier_stats(tier: u8) -> (i32, f32, i32, f32, f32) { + match tier { + 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), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum MobState { + Idle = 0, + Chase = 1, + Windup = 2, + Recover = 3, + Return = 4, + Dead = 5, +} + +#[derive(Serialize, Deserialize)] +struct Mob { + id: u32, + tier: u8, + home: (f32, f32), + x: f32, + y: f32, + face: f32, + hp: i32, + state: MobState, + timer: u16, + #[serde(skip)] + target: Option, + generation: u32, + respawn_at: u64, + wander: (f32, f32), +} + +struct Player { + id: EntityId, + principal: String, + name: String, + state: MoveState, + credits: u8, + hp: i32, + face: f32, + swing_cd: u8, + swing_anim: u8, + dead_until: Option, + regen: u8, + kills: u32, +} + +/// The persisted part of a room. +#[derive(Serialize, Deserialize)] +struct Snapshot { + mobs: Vec, + kills: u32, + ticks: u64, + activations: u32, + kills_by: BTreeMap, +} + +/// One Mob Pit room. +pub struct Pit { + players: BTreeMap, + mobs: Vec, + pillars: Vec, + /// Lifetime kills (persisted). + pub kills: u32, + /// Lifetime ticks (persisted; this activation adds `ctx.tick()`). + pub ticks_before: u64, + tick: u64, + /// Kills per principal (persisted). + pub kills_by: BTreeMap, + /// Activations including this one (persisted). + pub activations: u32, + /// Host info (set by the Durable Object). + pub host: HostInfo, +} + +fn mob_layout() -> Vec<(u8, (f32, f32))> { + (0..MOBS) + .map(|i| { + let ring = if i % 2 == 0 { 11.5 } else { 16.0 }; + let a = (i as f32 + 0.5) / MOBS as f32 * std::f32::consts::TAU; + let tier = match i { + 0 => 3, + 3 | 6 | 9 | 11 => 2, + _ => 1, + }; + (tier, (ring * a.cos(), ring * a.sin())) + }) + .collect() +} + +fn spawn_point(player: PlayerId) -> (f32, f32) { + const SPOTS: [(f32, f32); 6] = [ + (-4.5, -4.5), + (4.5, -4.5), + (4.5, 4.5), + (-4.5, 4.5), + (0.0, -5.5), + (0.0, 5.5), + ]; + 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 { + let (ox, oy) = (*x - px, *y - py); + let d2 = ox * ox + oy * oy; + let min = radius + pr; + if d2 < min * min { + let d = d2.sqrt().max(1e-4); + *x = px + ox / d * min; + *y = py + oy / d * min; + } + } + let lim = movement::HALF - radius; + *x = x.clamp(-lim, lim); + *y = y.clamp(-lim, lim); +} + +fn step_toward(x: &mut f32, y: &mut f32, tx: f32, ty: f32, stride: f32) -> f32 { + let (dx, dy) = (tx - *x, ty - *y); + let d = (dx * dx + dy * dy).sqrt(); + if d > 1e-4 { + let s = stride.min(d); + *x += dx / d * s; + *y += dy / d * s; + } + dy.atan2(dx) +} + +fn dist(a: (f32, f32), b: (f32, f32)) -> f32 { + ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() +} + +impl Pit { + /// Lifetime ticks. + pub fn ticks(&self) -> u64 { + self.ticks_before + self.tick + } + + /// Kills of one principal in this room. + pub fn kills_of(&self, principal: &str) -> u32 { + self.kills_by.get(principal).copied().unwrap_or(0) + } + + fn step_players(&mut self, ctx: &mut Ctx<'_, Self>) -> Vec<(PlayerId, f32, f32, f32)> { + let tick = ctx.tick(); + let mut swings = Vec::new(); + 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.swing_anim = p.swing_anim.saturating_sub(1); + if let Some(until) = p.dead_until { + let _ = ctx.take_inputs(*id, ConsumePolicy::Drain); + if tick >= until { + let (x, y) = spawn_point(*id); + p.state = MoveState { + x, + y, + ..MoveState::default() + }; + p.hp = PLAYER_HP; + p.dead_until = None; + } + continue; + } + while p.credits > 0 { + let Some(input) = ctx.take_inputs(*id, ConsumePolicy::Next).pop() else { + break; + }; + let input = input.input; + p.state = pit_step(&p.state, &input, &(), 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 { + p.face = sy.atan2(sx); + } + if input.attack() && p.swing_cd == 0 { + p.swing_cd = SWING_COOLDOWN; + p.swing_anim = SWING_FRAMES; + swings.push((*id, p.state.x, p.state.y, p.face)); + } + } + p.regen = p.regen.wrapping_add(1); + if p.regen % 15 == 0 && p.hp < PLAYER_HP { + p.hp += 1; + } + ctx.set_view(*id, View::new(p.state.x, p.state.y, VIEW_RADIUS)); + } + swings + } + + fn resolve_swings(&mut self, ctx: &mut Ctx<'_, Self>, swings: Vec<(PlayerId, f32, f32, f32)>) { + for (player, x, y, face) in swings { + let (fx, fy) = (face.cos(), face.sin()); + for i in 0..self.mobs.len() { + let m = &mut self.mobs[i]; + if m.state == MobState::Dead { + continue; + } + let (_, _, _, radius, _) = tier_stats(m.tier); + let (dx, dy) = (m.x - x, m.y - y); + let d = (dx * dx + dy * dy).sqrt(); + if d > ATTACK_RANGE + radius { + continue; + } + let cos = if d < 1e-3 { + 1.0 + } else { + (dx * fx + dy * fy) / d + }; + 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; + let (mob, tier) = (m.id, m.tier); + self.kills += 1; + if let Some(p) = self.players.get_mut(&player) { + let kills = self.kills_by.entry(p.principal.clone()).or_default(); + *kills += 1; + let kills = *kills; + p.kills = kills; + ctx.broadcast(&PitEvent::Kill { + mob, + tier, + killer: p.id.get(), + killer_name: p.name.clone(), + kills, + }); + } + } + } + } + } + + fn step_mobs(&mut self, ctx: &mut Ctx<'_, Self>) { + let dt = ctx.dt_secs(); + let tick = ctx.tick(); + let living: Vec<(PlayerId, f32, f32)> = self + .players + .iter() + .filter(|(_, p)| p.dead_until.is_none()) + .map(|(id, p)| (*id, p.state.x, p.state.y)) + .collect(); + let mut hits: Vec<(PlayerId, i32)> = Vec::new(); + for i in 0..self.mobs.len() { + let (max_hp, speed, damage, radius, tele) = tier_stats(self.mobs[i].tier); + 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]; + match m.state { + MobState::Dead => { + if tick >= m.respawn_at { + m.generation += 1; + m.hp = max_hp; + (m.x, m.y) = m.home; + m.state = MobState::Idle; + } + continue; + } + MobState::Idle => { + let nearest = living + .iter() + .map(|(id, x, y)| (*id, dist((m.x, m.y), (*x, *y)))) + .filter(|(_, d)| *d < AGGRO) + .min_by(|a, b| a.1.total_cmp(&b.1)); + if let Some((id, _)) = nearest { + m.state = MobState::Chase; + m.target = Some(id); + } else if dist((m.x, m.y), m.wander) < 0.3 { + let a = ctx.rng().next_f32() * std::f32::consts::TAU; + let r = ctx.rng().next_f32() * 2.5; + let m = &mut self.mobs[i]; + m.wander = (m.home.0 + r * a.cos(), m.home.1 + r * a.sin()); + } else { + let (wx, wy) = m.wander; + m.face = step_toward(&mut m.x, &mut m.y, wx, wy, 1.0 * dt); + } + } + MobState::Chase => match target_pos(m.target) { + Some((tx, ty)) if dist(m.home, (m.x, m.y)) < LEASH => { + let reach = radius + PLAYER_RADIUS + 0.6; + if dist((m.x, m.y), (tx, ty)) <= reach { + m.state = MobState::Windup; + 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.state = MobState::Return; + m.target = None; + } + }, + MobState::Windup => { + 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 { + hits.push((*id, damage)); + } + } + m.state = MobState::Recover; + m.timer = RECOVER_TICKS; + } + } + MobState::Recover => { + m.timer = m.timer.saturating_sub(1); + if m.timer == 0 { + m.state = MobState::Chase; + } + } + 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 dist((m.x, m.y), m.home) < 0.3 { + m.state = MobState::Idle; + m.wander = m.home; + } + } + } + let m = &mut self.mobs[i]; + push_out_of_pillars(&mut m.x, &mut m.y, radius); + } + for i in 0..self.mobs.len() { + for j in (i + 1)..self.mobs.len() { + let (a, b) = (&self.mobs[i], &self.mobs[j]); + if a.state == MobState::Dead || b.state == MobState::Dead { + continue; + } + let min = tier_stats(a.tier).3 + tier_stats(b.tier).3; + let (dx, dy) = (b.x - a.x, b.y - a.y); + let d = (dx * dx + dy * dy).sqrt(); + if d < min && d > 1e-4 { + let push = (min - d) * 0.5; + let (nx, ny) = (dx / d * push, dy / d * push); + self.mobs[i].x -= nx; + self.mobs[i].y -= ny; + self.mobs[j].x += nx; + self.mobs[j].y += ny; + } + } + } + for (id, damage) in hits { + let Some(p) = self.players.get_mut(&id) else { + continue; + }; + if p.dead_until.is_some() { + continue; + } + p.hp -= damage; + if p.hp <= 0 { + p.hp = 0; + p.state.vx = 0.0; + p.state.vy = 0.0; + p.dead_until = Some(tick + PLAYER_RESPAWN_TICKS); + for m in &mut self.mobs { + if m.target == Some(id) { + m.target = None; + } + } + ctx.send( + id, + &PitEvent::Died { + respawn_ms: (PLAYER_RESPAWN_TICKS * 1000 / 30) as u32, + }, + ); + } + } + } +} + +impl Zone for Pit { + type Config = (); + type Input = PitInput; + type Timer = (); + type Message = distributed_game::NoMessages; + + fn schema() -> EntitySchema { + schema![PlayerView, MobView, PillarView, PitView, HostView] + } + + fn entities() -> Entities { + Entities::new() + .each(|z: &Self| &z.players) + .each(|z: &Self| &z.mobs) + .each(|z: &Self| &z.pillars) + .one(|z: &Self| z) + .one(|z: &Self| &z.host) + } + + fn on_create(_ctx: &mut Ctx<'_, Self>, _config: ()) -> Self { + let mobs = mob_layout() + .into_iter() + .enumerate() + .map(|(i, (tier, home))| Mob { + id: i as u32 + 1, + tier, + home, + x: home.0, + y: home.1, + face: 0.0, + hp: tier_stats(tier).0, + state: MobState::Idle, + timer: 0, + target: None, + generation: 0, + respawn_at: 0, + wander: home, + }) + .collect(); + Self { + players: BTreeMap::new(), + mobs, + pillars: Pillar::all(), + kills: 0, + ticks_before: 0, + tick: 0, + kills_by: BTreeMap::new(), + activations: 1, + host: HostInfo::default(), + } + } + + fn on_join(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId) { + let (principal, name) = ctx + .player(player) + .map(|p| (p.principal.clone(), p.name.clone())) + .unwrap_or_default(); + let name = name.unwrap_or_else(|| principal.clone()); + let kills = self.kills_of(&principal); + let (x, y) = spawn_point(player); + let entity = EntityId(1000 + player.0); + self.players.insert( + player, + Player { + id: entity, + principal, + name, + state: MoveState { + x, + y, + ..MoveState::default() + }, + credits: MAX_CREDITS, + hp: PLAYER_HP, + face: 0.0, + swing_cd: 0, + swing_anim: 0, + dead_until: None, + regen: 0, + kills, + }, + ); + ctx.set_view(player, View::new(x, y, VIEW_RADIUS)); + ctx.send( + player, + &PitEvent::Hello { + entity: entity.get(), + half: movement::HALF, + boot: self.host.activation.clone(), + attack_range: ATTACK_RANGE, + attack_arc_cos: ATTACK_ARC_COS, + }, + ); + } + + fn on_leave(&mut self, _ctx: &mut Ctx<'_, Self>, player: PlayerId, _reason: LeaveReason) { + self.players.remove(&player); + for m in &mut self.mobs { + if m.target == Some(player) { + m.target = None; + } + } + } + + fn on_observe(&mut self, ctx: &mut Ctx<'_, Self>, observer: ObserverId) { + ctx.set_view(observer, View::new(0.0, 0.0, VIEW_RADIUS)); + } + + fn decode_input(&self, _player: PlayerId, payload: &[u8]) -> Option { + PitInput::from_payload(payload) + } + + fn on_tick(&mut self, ctx: &mut Ctx<'_, Self>) { + self.tick = ctx.tick(); + let swings = self.step_players(ctx); + self.resolve_swings(ctx, swings); + self.step_mobs(ctx); + } + + fn hibernate(&self) -> Option> { + #[derive(Serialize)] + struct SnapshotRef<'a> { + mobs: &'a [Mob], + kills: u32, + ticks: u64, + activations: u32, + kills_by: &'a BTreeMap, + } + serde_json::to_vec(&SnapshotRef { + mobs: &self.mobs, + kills: self.kills, + ticks: self.ticks(), + activations: self.activations, + kills_by: &self.kills_by, + }) + .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; + }; + // Respawn timers are tick numbers of the old activation. + self.mobs = snapshot + .mobs + .into_iter() + .map(|mut m| { + m.respawn_at = m + .respawn_at + .saturating_sub(snapshot.ticks.min(m.respawn_at)); + if m.state != MobState::Dead { + m.state = MobState::Return; + } + m + }) + .collect(); + self.kills = snapshot.kills; + self.ticks_before = snapshot.ticks; + self.activations = snapshot.activations + 1; + self.kills_by = snapshot.kills_by; + } +} + +impl Entity for Player { + fn entity_id(&self) -> EntityId { + self.id + } +} + +impl Project for Player { + fn view(&self) -> Option { + Some(PlayerView::project( + self, + PlayerViewRest { + hp: self.hp.clamp(0, 255) as u8, + swing: self.swing_anim, + level: self.kills.min(u32::from(u16::MAX)) as u16, + dead: self.dead_until.is_some(), + }, + )) + } +} + +impl Entity for Mob { + fn entity_id(&self) -> EntityId { + EntityId(self.id) + } +} + +impl Project for Mob { + fn view(&self) -> Option { + if self.state == MobState::Dead { + return None; + } + let max_hp = tier_stats(self.tier).0; + let tele = if self.state == MobState::Windup { + 255 - (u32::from(self.timer) * 255 / u32::from(WINDUP_TICKS)) + } else { + 0 + }; + Some(MobView::project( + self, + MobViewRest { + hp: (self.hp.max(0) * 100 / max_hp) as u8, + state: self.state as u8, + tele: tele as u8, + }, + )) + } +} + +impl Entity for Pit { + fn entity_id(&self) -> EntityId { + PIT_ENTITY + } +} + +impl Project for Pit { + fn view(&self) -> Option { + Some(PitView::project( + self, + PitViewRest { + kills: self.kills, + ticks: self.ticks().min(u64::from(u32::MAX)) as u32, + }, + )) + } +} diff --git a/examples/game/zone-do-spike/tools/baseline.mjs b/examples/game/zone-do-spike/tools/baseline.mjs new file mode 100644 index 00000000..5809396a --- /dev/null +++ b/examples/game/zone-do-spike/tools/baseline.mjs @@ -0,0 +1,5 @@ +// Noise floor: setTimeout 30 Hz loop lateness in a plain Node process. +const hz = Number(process.argv[2] ?? 30), secs = Number(process.argv[3] ?? 15), step = 1000 / hz; +const start = performance.now(); const late = []; let k = 1; +function tick() { const now = performance.now(); late.push(now - (start + (k - 1) * step)); k = Math.floor((now - start) / step) + 2; if (now - start < secs * 1000) setTimeout(tick, Math.max(0, start + (k - 1) * step - performance.now())); else { late.shift(); const s = late.sort((a, b) => a - b); console.log(JSON.stringify({ baselineNodeTimer: { hz, p50: +s[Math.floor(s.length / 2)].toFixed(2), p99: +s[Math.floor(s.length * 0.99)].toFixed(2), max: +s.at(-1).toFixed(2) } })); } } +setTimeout(tick, step); diff --git a/examples/game/zone-do-spike/tools/bench.ts b/examples/game/zone-do-spike/tools/bench.ts new file mode 100644 index 00000000..1d5e99ef --- /dev/null +++ b/examples/game/zone-do-spike/tools/bench.ts @@ -0,0 +1,166 @@ +// Spike gr-p42 measurements with raw protocol bots (no rendering). +// +// node bench.mjs join join phase timings +// node bench.mjs rtt [n] Ping/Pong RTT through +// node bench.mjs load [hz] [pids] +// one bot per room (moves + attacks at 30 Hz); all rooms first touched +// through so celld places them on that node. Reports the +// rooms' own loop timing (/rooms//stats), Delta inter-arrival at +// the bots and the node processes' CPU (ps) while loaded. +import { execFileSync } from 'node:child_process'; +import { decodeServerMessage, encodeClientMessage, encodePayload, type PayloadLayout } from '../../../../js/src/game/index.ts'; + +const INPUT: PayloadLayout = [['mx', { type: 'i8' }], ['my', { type: 'i8' }], ['buttons', { type: 'u8' }]]; +const TAG_DELTA = 0x81; // GameMessageTag.DELTA +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const q = (a: number[], p: number) => { if (!a.length) return NaN; const s = [...a].sort((x, y) => x - y); return +s[Math.round(p * (s.length - 1))].toFixed(2); }; +const summary = (a: number[]) => ({ n: a.length, p50: q(a, 0.5), p99: q(a, 0.99), max: q(a, 1) }); + +type Bot = { ws: WebSocket; deltas: number[]; pongs: Map void>; seq: number; timings: Record }; + +async function bot(base: string, room: string, name: string): Promise { + const t0 = performance.now(); + const r = await fetch(`${base}/ticket?room=${encodeURIComponent(room)}&name=${name}`); + const { ticket } = (await r.json()) as { ticket: string }; + const tTicket = performance.now(); + const ws = new WebSocket(`${base.replace(/^http/, 'ws')}/rooms/${encodeURIComponent(room)}`); + ws.binaryType = 'arraybuffer'; + const b: Bot = { ws, deltas: [], pongs: new Map(), seq: 0, timings: {} }; + await new Promise((ok, fail) => { ws.onopen = () => ok(); ws.onerror = () => fail(new Error(`ws error ${room}`)); }); + const tOpen = performance.now(); + ws.send(encodeClientMessage({ type: 'Hello', protocolVersion: 1, contentVersion: '', capabilities: 0, ticket, reconnectToken: '' })); + await new Promise((ok, fail) => { + let welcomed = false; + ws.onclose = (e) => { if (!welcomed) fail(new Error(`closed ${e.code} ${e.reason}`)); }; + ws.onmessage = (e) => { + const bytes = new Uint8Array(e.data as ArrayBuffer); + const now = performance.now(); + if (bytes[0] === TAG_DELTA) { b.deltas.push(now); if (!b.timings.firstDelta) b.timings.firstDelta = now - t0; return; } + const m = decodeServerMessage(bytes); + if (m.type === 'Welcome') { welcomed = true; b.timings.welcome = now - t0; ok(); } + if (m.type === 'Pong') b.pongs.get(m.clientTime)?.(now); + if (m.type === 'Error') fail(new Error(`error ${m.code} ${m.message}`)); + }; + }); + b.timings.ticket = tTicket - t0; + b.timings.open = tOpen - tTicket; + b.timings.hello = b.timings.welcome - (tOpen - t0); + return b; +} + +function ping(b: Bot): Promise { + const t = performance.now(); + return new Promise((ok) => { b.pongs.set(t, (now) => { b.pongs.delete(t); ok(now - t); }); b.ws.send(encodeClientMessage({ type: 'Ping', clientTime: t })); }); +} + +function drive(b: Bot, phase: number) { + let k = phase; + return setInterval(() => { + k++; + const a = k / 40; + b.seq++; + b.ws.send(encodeClientMessage({ type: 'Input', seq: b.seq, clientTick: b.seq, payload: encodePayload(INPUT, { mx: Math.round(Math.cos(a) * 100), my: Math.round(Math.sin(a) * 100), buttons: 2 }) })); + }, 1000 / 30); +} + +function cpu(pids: string[]) { + if (!pids.length) return []; + const out = execFileSync('ps', ['-o', 'pid=,%cpu=,rss=', '-p', pids.join(',')]).toString().trim().split('\n'); + return out.map((l) => { const [pid, c, rss] = l.trim().split(/\s+/); return { pid, cpu: +c, rssMb: Math.round(+rss / 1024) }; }); +} + +const [mode, base, room, ...rest] = process.argv.slice(2); +if (mode === 'join') { + // Warm the Node HTTP/WebSocket stack (its first use costs 50-500 ms). + const w = await bot(base, 'warmup', 'w'); + w.ws.close(); + for (const r of room.split(',')) { + const b = await bot(base, r, 'joiner'); + await sleep(200); + console.log(JSON.stringify({ room: r, via: base, ...Object.fromEntries(Object.entries(b.timings).map(([k, v]) => [k, +v.toFixed(1)])) })); + b.ws.close(); + } +} else if (mode === 'wake') { + // wake : create n rooms + // through the owner (fresh activation), leave, wait for eviction, then + // rejoin each (even through the owner, odd through ). + const [n, waitS, other] = rest; + const w = await bot(base, 'warmup', 'w'); w.ws.close(); + const w2 = await bot(other, 'warmup', 'w'); w2.ws.close(); + const tag = Date.now().toString(36); + const rooms = Array.from({ length: Number(n) }, (_, i) => `${room}-${tag}-${i}`); + const fresh: number[] = []; + const before: Record = {}; const after: Record = {}; const moved: string[] = []; + const ownerOf = async (r: string) => ((await (await fetch(`${base}/rooms/${encodeURIComponent(r)}/stats`)).json()) as any).node as string; + const first: string[] = []; + for (const r of rooms) { const b = await bot(base, r, 'x'); fresh.push(b.timings.welcome); await sleep(300); const o = await ownerOf(r); first.push(o); before[o] = (before[o] ?? 0) + 1; b.ws.close(); } + await sleep(Number(waitS) * 1000); + const viaOwner: number[] = []; const viaOther: number[] = []; const restored: boolean[] = []; + for (const [i, r] of rooms.entries()) { + const b = await bot(i % 2 ? other : base, r, 'x'); + (i % 2 ? viaOther : viaOwner).push(b.timings.welcome); + b.ws.close(); + const st = await (await fetch(`${base}/rooms/${encodeURIComponent(r)}/stats`)).json() as any; + restored.push(st.restored && st.activations === 2); + after[st.node] = (after[st.node] ?? 0) + 1; + if (st.node !== first[i]) moved.push(`${r}:${first[i]}->${st.node}`); + } + console.log(JSON.stringify({ fresh: summary(fresh), wakeViaOwner: summary(viaOwner), wakeViaOther: summary(viaOther), restoredAll: restored.every(Boolean), restored: restored.filter(Boolean).length, ownersBefore: before, ownersAfter: after, moved: moved.length, movedRooms: moved.slice(0, 5) })); +} else if (mode === 'rtt') { + const n = Number(rest[0] ?? 200); + const b = await bot(base, room, 'pinger'); + const rtts: number[] = []; + const t0 = performance.now(); const spikes: string[] = []; + for (let i = 0; i < n; i++) { const r = await ping(b); rtts.push(r); if (r > 15) spikes.push(`${((performance.now() - t0) / 1000).toFixed(2)}s:${r.toFixed(0)}`); await sleep(10); } + if (process.env.SPIKES) console.log('spikes', spikes.join(' ')); + const st = await (await fetch(`${base}/rooms/${encodeURIComponent(room)}/stats`)).json() as any; + console.log(JSON.stringify({ room, via: base, owner: st.node, rtt: summary(rtts) })); + b.ws.close(); +} else if (mode === 'load') { + const [count, secs, hz = '30', pidList = ''] = rest; + const pids = pidList ? pidList.split(',') : []; + const rooms = Array.from({ length: Number(count) }, (_, i) => `${room}-${i}@${hz}`); + const bots: Bot[] = []; + const t0 = performance.now(); + for (let i = 0; i < rooms.length; i += 20) { + bots.push(...(await Promise.all(rooms.slice(i, i + 20).map((r, j) => bot(base, r, `b${i + j}`))))); + } + const joinS = (performance.now() - t0) / 1000; + const timers = bots.map((b, i) => drive(b, i * 7)); + await sleep(3000); + await Promise.all(rooms.map((r) => fetch(`${base}/rooms/${encodeURIComponent(r)}/stats?reset=1`).catch(() => undefined))); + for (const b of bots) b.deltas.length = 0; + const samples: ReturnType[] = []; + const end = Date.now() + Number(secs) * 1000; + while (Date.now() < end) { await sleep(1000); samples.push(cpu(pids)); } + const fetched = await Promise.all(rooms.map(async (r) => { try { return await (await fetch(`${base}/rooms/${encodeURIComponent(r)}/stats`)).json() as any; } catch { return undefined; } })); + const stats = fetched.filter(Boolean); + if (stats.length < rooms.length) console.log(`stats unavailable for ${rooms.length - stats.length} rooms`); + const rtts: number[] = []; + for (const b of bots.slice(0, 10)) rtts.push(await ping(b)); + timers.forEach(clearInterval); + const late99 = stats.map((s) => s.late_ms.p99); + const late50 = stats.map((s) => s.late_ms.p50); + const interval = stats.map((s) => s.interval_ms.p99); + const dropped = stats.reduce((a, s) => a + s.dropped_ticks, 0); + const catchUp = stats.reduce((a, s) => a + s.catch_up_iterations, 0); + const iterations = stats.reduce((a, s) => a + s.iterations, 0); + const arrivals = bots.flatMap((b) => b.deltas.slice(1).map((t, i) => t - b.deltas[i])); + const nodes: Record = {}; + for (const s of stats) nodes[s.node] = (nodes[s.node] ?? 0) + 1; + const isolates = new Set(stats.map((s) => s.isolate)).size; + const cpuByPid: Record = {}; + for (const s of samples) for (const c of s) (cpuByPid[c.pid] ??= []).push(c.cpu); + console.log(JSON.stringify({ + rooms: rooms.length, hz: Number(hz), secs: Number(secs), joinS: +joinS.toFixed(1), nodes, isolates, + lateP50_median: q(late50, 0.5), lateP99_median: q(late99, 0.5), lateP99_worstRoom: q(late99, 1), + intervalP99_median: q(interval, 0.5), droppedTicks: dropped, catchUpIterations: catchUp, iterations, + expectedIterations: Math.round(rooms.length * Number(hz) * Number(secs)), + deltaArrival: summary(arrivals), pingUnderLoad: summary(rtts), + cpu: Object.fromEntries(Object.entries(cpuByPid).map(([p, a]) => [p, { avg: +(a.reduce((x, y) => x + y, 0) / a.length).toFixed(1), max: Math.max(...a) }])), + rss: samples.at(-1) + })); + for (const b of bots) b.ws.close(); + await sleep(500); +} +process.exit(0); diff --git a/examples/game/zone-do-spike/tools/drain.ts b/examples/game/zone-do-spike/tools/drain.ts new file mode 100644 index 00000000..f5be9ae9 --- /dev/null +++ b/examples/game/zone-do-spike/tools/drain.ts @@ -0,0 +1,28 @@ +// Graceful node stop: create rooms through , leave, wait for +// eviction, print READY (operator stops that node with SIGTERM), then +// rejoin each through and report where it runs now. +import { connectRoom } from '../page/room-client.ts'; +const [ownerBase, entry, n, waitS] = process.argv.slice(2); +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const tag = Date.now().toString(36); +const rooms = Array.from({ length: Number(n) }, (_, i) => `dr-${tag}-${i}`); +const node = async (base: string, r: string) => ((await (await fetch(`${base}/rooms/${encodeURIComponent(r)}/stats`)).json()) as any); +const before: Record = {}; +for (const r of rooms) { + const rc = connectRoom(ownerBase, r, 'd'); await rc.welcome; await sleep(400); rc.close(); + const st = await node(ownerBase, r); before[st.node] = (before[st.node] ?? 0) + 1; +} +console.log('placed', JSON.stringify(before)); +await sleep(Number(waitS) * 1000); +console.log('READY'); +for (let i = 0; i < 200 && !(await fetch(`${ownerBase}/ticket`).then(() => false, () => true)); i++) await sleep(100); +const t0 = performance.now(); +const after: Record = {}; let restored = 0; const waits: number[] = []; +for (const r of rooms) { + const t = performance.now(); + for (;;) { try { const rc = connectRoom(entry, r, 'd'); await rc.welcome; rc.close(); break; } catch { await sleep(200); } } + waits.push(performance.now() - t); + const st = await node(entry, r); after[st.node] = (after[st.node] ?? 0) + 1; if (st.restored) restored++; +} +console.log('after stop', JSON.stringify({ owners: after, restored, rooms: rooms.length, firstRejoinMs: Math.round(waits[0]), allRejoinedS: +((performance.now() - t0) / 1000).toFixed(1) })); +process.exit(0); diff --git a/examples/game/zone-do-spike/tools/failover.ts b/examples/game/zone-do-spike/tools/failover.ts new file mode 100644 index 00000000..7809d5b4 --- /dev/null +++ b/examples/game/zone-do-spike/tools/failover.ts @@ -0,0 +1,48 @@ +// Node failure: a room placed on one node (first touch through ) +// with a player connected through another node (); the +// operator SIGKILLs the owner when this prints READY. The player then +// rejoins through until a room answers again. +// usage: node failover.mjs +import { connectRoom, ATTACK } from '../page/room-client.ts'; +const [ownerBase, entry, room] = process.argv.slice(2); +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const log = (...a: unknown[]) => console.log(new Date().toISOString().slice(11, 23), ...a); +// First touch through the owner places the room there. +await (await fetch(`${ownerBase}/rooms/${encodeURIComponent(room)}/stats`)).json(); +const rc = connectRoom(entry, room, 'survivor'); +await rc.welcome; +for (let i = 0; i < 100 && !(rc.host()?.node && rc.host()!.node !== '?'); i++) await sleep(50); +log(`joined ${room} via ${entry}; runs on ${rc.host()?.node}`); +let closedAt = 0; +rc.client.on('zoneClosed', (_z, info) => { closedAt = performance.now(); log('connection closed', JSON.stringify(info)); }); +const fight = setInterval(() => { + const me = rc.me()?.fields as any; const mobs = rc.entities().filter((e) => e.kindName === 'mob').map((e) => e.fields as any); + if (!me || !mobs.length) return; + const t = mobs.sort((a, b) => Math.hypot(a.x - me.x, a.y - me.y) - Math.hypot(b.x - me.x, b.y - me.y))[0]; + const [dx, dy] = [t.x - me.x, t.y - me.y]; const d = Math.hypot(dx, dy) || 1; const s = d < 1.8 ? 30 : 127; + rc.input(Math.round(dx / d * s), Math.round(dy / d * s), ATTACK); +}, 33); +while (rc.kills.length < 3) await sleep(100); +await sleep(6000); // > the room's 5 s persist interval +const before = rc.kills.at(-1)!.kills; +log(`kills before failure: ${before} (persisted at most 5 s ago); READY`); +const t0 = performance.now(); +while (!closedAt && performance.now() - t0 < 60_000) await sleep(20); +clearInterval(fight); +const killDetected = closedAt; +let attempt = 0; +for (;;) { + attempt++; + try { + const r2 = connectRoom(entry, room, 'survivor'); + await r2.welcome; + for (let i = 0; i < 100 && !(r2.host()?.node && r2.host()!.node !== '?' && r2.me()); i++) await sleep(50); + const h = r2.host()!; const me = r2.me()?.fields as any; + log(`rejoined after ${((performance.now() - killDetected) / 1000).toFixed(2)} s (${attempt} attempts): node=${h.node} activation #${h.activations} restored=${h.restored}; my kills ${me?.level} (had ${before})`); + r2.close(); break; + } catch (e) { + if (attempt % 10 === 1) log(`attempt ${attempt}: ${String(e).slice(0, 120)}`); + await sleep(250); + } +} +process.exit(0); diff --git a/examples/game/zone-do-spike/tools/headless.ts b/examples/game/zone-do-spike/tools/headless.ts new file mode 100644 index 00000000..545a37e1 --- /dev/null +++ b/examples/game/zone-do-spike/tools/headless.ts @@ -0,0 +1,65 @@ +// Headless check of one room on celld: join, move, attack until a kill, +// leave, wait for eviction, rejoin (possibly via another node) and check +// the room restored its state in a new activation. +// usage: node headless.mjs [rejoin-base-url] [evict-wait-s] +import { connectRoom, ATTACK, type RoomClient } from '../page/room-client.ts'; + +const [base, room, rejoinBase = base, waitS = '25'] = process.argv.slice(2); +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const until = async (f: () => T | undefined | false, ms: number, what: string): Promise => { + const end = Date.now() + ms; + for (;;) { const v = f(); if (v) return v; if (Date.now() > end) throw new Error(`timeout: ${what}`); await sleep(20); } +}; +const log = (...a: unknown[]) => console.log(new Date().toISOString().slice(11, 23), ...a); + +async function join(url: string, name: string) { + const t0 = performance.now(); + const rc = connectRoom(url, room, name); + await rc.welcome; + const welcomeMs = performance.now() - t0; + await until(() => rc.host()?.node && rc.host()!.node !== '?' && rc.me(), 5000, 'host node + own player'); + return { rc, welcomeMs }; +} + +async function fight(rc: RoomClient, kills: number) { + const start = rc.kills.length; + const end = Date.now() + 60_000; + let moved = 0; + const at0 = rc.me()!.fields as any; + while (rc.kills.length - start < kills) { + if (Date.now() > end) throw new Error('no kill within 60 s'); + const me = rc.me()?.fields as any; + const mobs = rc.entities().filter((e) => e.kindName === 'mob').map((e) => e.fields as any); + if (me && mobs.length) { + const t = mobs.sort((a, b) => Math.hypot(a.x - me.x, a.y - me.y) - Math.hypot(b.x - me.x, b.y - me.y))[0]; + const [dx, dy] = [t.x - me.x, t.y - me.y]; const d = Math.hypot(dx, dy) || 1; + const near = d < 1.8; + rc.input(near ? Math.round(dx / d * 30) : Math.round(dx / d * 127), near ? Math.round(dy / d * 30) : Math.round(dy / d * 127), ATTACK); + moved = Math.max(moved, Math.hypot(me.x - at0.x, me.y - at0.y)); + } + await sleep(33); + } + return { moved }; +} + +const { rc, welcomeMs } = await join(base, 'bot-ana'); +const h1 = { ...rc.host()! }; +log(`joined ${room} via ${base}: node=${h1.node} isolate=${h1.isolate} activation=${h1.activation} #${h1.activations} restored=${h1.restored} welcome=${welcomeMs.toFixed(1)}ms`); +const kills0 = (rc.entities().find((e) => e.kindName === 'pit')?.fields as any)?.kills; +const { moved } = await fight(rc, 1); +await sleep(500); +const k = rc.kills.at(-1)!; +log(`moved ${moved.toFixed(1)} m, killed mob ${k.mob}; my kills in room=${k.kills}; room kills ${kills0} -> ${(rc.entities().find((e) => e.kindName === 'pit')?.fields as any)?.kills}`); +rc.client.close(); +log(`left; waiting ${waitS}s for the room to persist, stop and be evicted`); +await sleep(Number(waitS) * 1000); +const r2 = await join(rejoinBase, 'bot-ana'); +const h2 = { ...r2.rc.host()! }; +const me2 = r2.rc.me()!.fields as any; +const pit2 = r2.rc.entities().find((e) => e.kindName === 'pit')?.fields as any; +log(`rejoined via ${rejoinBase}: node=${h2.node} isolate=${h2.isolate} activation=${h2.activation} #${h2.activations} restored=${h2.restored} wake=${(h2 as any).wake_ms}ms welcome=${r2.welcomeMs.toFixed(1)}ms`); +log(`restored state: my kills=${me2.level} (expected ${k.kills}), room kills=${pit2?.kills}, lifetime ticks=${pit2?.ticks}`); +const ok = h2.activation !== h1.activation && h2.restored && me2.level === k.kills && pit2?.kills >= k.kills; +r2.rc.client.close(); +log(ok ? 'PASS' : 'FAIL'); +process.exit(ok ? 0 : 1); diff --git a/examples/game/zone-do-spike/tools/peek.ts b/examples/game/zone-do-spike/tools/peek.ts new file mode 100644 index 00000000..2d4855b6 --- /dev/null +++ b/examples/game/zone-do-spike/tools/peek.ts @@ -0,0 +1,11 @@ +// Print one room's replicated host entity and stats. +import { connectRoom } from '../page/room-client.ts'; +const [base, room] = process.argv.slice(2); +const t0 = performance.now(); +const rc = connectRoom(base, room, 'peek'); +await rc.welcome; +const tw = performance.now() - t0; +for (let i = 0; i < 100 && !(rc.host() as any)?.node; i++) await new Promise((r) => setTimeout(r, 20)); +await new Promise((r) => setTimeout(r, 1500)); +console.log(JSON.stringify({ welcomeMs: +tw.toFixed(1), host: rc.host(), rtt: rc.rtt() })); +rc.client.close(); process.exit(0); diff --git a/examples/game/zone-do-spike/tools/s3-compose.yaml b/examples/game/zone-do-spike/tools/s3-compose.yaml new file mode 100644 index 00000000..b0b1d8e9 --- /dev/null +++ b/examples/game/zone-do-spike/tools/s3-compose.yaml @@ -0,0 +1,13 @@ +# The spike's one S3-compatible store (RustFS) for the celld fleet bucket. +# Compose rather than `docker run`: Dory (this machine's Docker) published +# ports only for containers on a user-defined network. +services: + s3: + image: rustfs/rustfs:1.0.3 + container_name: zone-do-spike-s3 + command: ["/data"] + environment: + RUSTFS_ADDRESS: "0.0.0.0:9000" + RUSTFS_ACCESS_KEY: spikeadmin + RUSTFS_SECRET_KEY: spikesecret123 + ports: ["127.0.0.1:${S3_PORT:-19400}:9000"] diff --git a/examples/game/zone-do-spike/wrangler.jsonc b/examples/game/zone-do-spike/wrangler.jsonc new file mode 100644 index 00000000..70455c40 --- /dev/null +++ b/examples/game/zone-do-spike/wrangler.jsonc @@ -0,0 +1,20 @@ +// Spike gr-p42: Mob Pit rooms as celld Durable Objects. `./run.sh deploy` +// publishes this to the fleet bucket (`celld deploy`); every node runs it. +{ + "name": "zone-do-spike", + "main": "build/worker/shim.mjs", + "compatibility_date": "2026-01-01", + "durable_objects": { + "bindings": [{ "name": "PIT_ROOM", "class_name": "PitRoom" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["PitRoom"] }], + "assets": { "directory": "./public", "binding": "ASSETS" }, + "vars": { + // Dev-only ticket key (the spike's /ticket endpoint signs with it). + "TICKET_SECRET": "spike-only-ticket-secret-not-for-production", + "TICK_HZ": "30", + // Operator listeners, only to find out which node runs a room + // (celld does not tell application code its node id). + "NODES": "node-a=127.0.0.1:18201,node-b=127.0.0.1:18202,node-c=127.0.0.1:18203" + } +} From 264b967262c87d1cb9c3f2cd3f11a78d7cecda1a Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 18:08:08 -0500 Subject: [PATCH 02/87] test(spike): crowd mode, many players in one room [[tasks/gr-p42-zones-as-celld-objects-spike]] --- examples/game/zone-do-spike/tools/bench.ts | 53 ++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/examples/game/zone-do-spike/tools/bench.ts b/examples/game/zone-do-spike/tools/bench.ts index 1d5e99ef..ba5e6162 100644 --- a/examples/game/zone-do-spike/tools/bench.ts +++ b/examples/game/zone-do-spike/tools/bench.ts @@ -116,6 +116,59 @@ if (mode === 'join') { const st = await (await fetch(`${base}/rooms/${encodeURIComponent(room)}/stats`)).json() as any; console.log(JSON.stringify({ room, via: base, owner: st.node, rtt: summary(rtts) })); b.ws.close(); +} else if (mode === 'crowd') { + // crowd [hz] [pids] [bases]: bots + // in ONE room, joins spread over [bases] (comma list, default ), so + // every bot not on the owner node is forwarded. Reports the room's loop + // timing, per-bot Delta size/interval/bandwidth, ping and node CPU. + const [count, secs, hz = '30', pidList = '', baseList = ''] = rest; + const pids = pidList ? pidList.split(',') : []; + const bases = baseList ? baseList.split(',') : [base]; + const r = `${room}@${hz}`; + const bots: Bot[] = []; + const bytes: number[][] = []; + const t0 = performance.now(); + for (let i = 0; i < Number(count); i += 10) { + const batch = await Promise.all(Array.from({ length: Math.min(10, Number(count) - i) }, (_, j) => bot(bases[(i + j) % bases.length], r, `p${i + j}`))); + for (const b of batch) { + const sizes: number[] = []; + bytes.push(sizes); + const prev = b.ws.onmessage!; + b.ws.onmessage = (e) => { const d = e.data as ArrayBuffer; if (new Uint8Array(d)[0] === TAG_DELTA) sizes.push(d.byteLength); (prev as any).call(b.ws, e); }; + bots.push(b); + } + } + const joinS = (performance.now() - t0) / 1000; + const welcome = bots.map((b) => b.timings.welcome); + const timers = bots.map((b, i) => drive(b, i * 7)); + await sleep(3000); + await fetch(`${base}/rooms/${encodeURIComponent(r)}/stats?reset=1`).catch(() => undefined); + for (const b of bots) b.deltas.length = 0; + for (const s of bytes) s.length = 0; + const samples: ReturnType[] = []; + const end = Date.now() + Number(secs) * 1000; + while (Date.now() < end) { await sleep(1000); samples.push(cpu(pids)); } + const st = (await (await fetch(`${base}/rooms/${encodeURIComponent(r)}/stats`)).json()) as any; + const rtts: number[] = []; + for (const b of bots.filter((_, i) => i % 5 === 0)) rtts.push(await ping(b)); + timers.forEach(clearInterval); + const arrivals = bots.flatMap((b) => b.deltas.slice(1).map((t, i) => t - b.deltas[i])); + const sizes = bytes.flat(); + const perBotKBps = bytes.map((s) => s.reduce((a, x) => a + x, 0) / 1024 / Number(secs)); + const cpuByPid: Record = {}; + for (const s of samples) for (const c of s) (cpuByPid[c.pid] ??= []).push(c.cpu); + console.log(JSON.stringify({ + room: r, players: bots.length, connected: bots.filter((b) => b.ws.readyState === 1).length, hz: Number(hz), secs: Number(secs), + joinS: +joinS.toFixed(1), welcome: summary(welcome), owner: st.node, + lateP50: st.late_ms?.p50, lateP99: st.late_ms?.p99, intervalP99: st.interval_ms?.p99, droppedTicks: st.dropped_ticks, + iterations: st.iterations, expectedIterations: Math.round(Number(hz) * Number(secs)), + deltaBytes: summary(sizes), deltaArrival: summary(arrivals), + perBotKBps: summary(perBotKBps), roomOutKBps: +perBotKBps.reduce((a, x) => a + x, 0).toFixed(1), + pingUnderLoad: summary(rtts), + cpu: Object.fromEntries(Object.entries(cpuByPid).map(([p, a]) => [p, { avg: +(a.reduce((x, y) => x + y, 0) / a.length).toFixed(1), max: Math.max(...a) }])) + })); + for (const b of bots) b.ws.close(); + await sleep(500); } else if (mode === 'load') { const [count, secs, hz = '30', pidList = ''] = rest; const pids = pidList ? pidList.split(',') : []; From fd647aa872f337341fccc36a3515f2929563fb08 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 19:05:35 -0500 Subject: [PATCH 03/87] feat(game)!: celld zone host, zones as Durable Objects distributed_game's `celld-zone` feature hosts any Zone as a celld Durable Object, one object per zone id: celld places, forwards, evicts, rebalances idle objects and recovers crashed ones; game code never names a server. The spike's PitRoom glue becomes framework code: - ZoneObject + celld_zone_object!: hibernatable WebSocket joins (ticket, single-use transfer ticket, reconnect token), a self-correcting deadline tick loop while occupied, snapshots in object storage, dormant -> hibernated by object alarm, restore on wake, 1012 for sockets of an earlier activation, GET .../info debug JSON. - Facts object to object: CelldObjectHost under CommandFactSink, with a durable outbox in object storage (FactQueue journal) so facts reach cells exactly once across crashes. - world::zone_activity: the zone reports active / dormant / hibernated as facts into its ZoneActivity cell; zone_status projects them into zone_activities (ZoneStatus::Dormant, of_activity). - Debug-only node lookup via operator listeners (CELLD_ZONE_NODES), off by default. Breaking: CommandFactSink is portable (feature `cell`) over the new FactHost trait, which replaces FactRouting and the CommandHost bound. The JS GameConnection rejoins with a fresh ticket on close 1012 (GAME_CLOSE_RESTARTED). Tests: celld_zone on celld dev and on a 3-node RustFS fleet (owner killed mid-flight: 1012, recovery elsewhere, 200 facts exactly once), zone_status activity projection, plan unit tests, wasm32 domain check. [[tasks/gr-p43-celld-zone-host]] --- .github/workflows/integration-celld.yaml | 8 + Cargo.toml | 2 +- distributed_game/Cargo.toml | 21 +- distributed_game/README.md | 71 +- distributed_game/src/celld_zone/cells.rs | 189 +++ distributed_game/src/celld_zone/mod.rs | 85 + distributed_game/src/celld_zone/object.rs | 1407 +++++++++++++++++ distributed_game/src/celld_zone/plan.rs | 473 ++++++ distributed_game/src/command_sink.rs | 200 ++- distributed_game/src/facts.rs | 21 + distributed_game/src/host.rs | 10 + distributed_game/src/lib.rs | 7 +- distributed_game/src/world/mod.rs | 13 +- .../save_zone_activity.mutation.graphql | 5 + distributed_game/src/world/zone_activity.rs | 288 ++++ distributed_game/src/world/zone_status.rs | 139 +- distributed_game/tests/celld_zone/main.rs | 420 +++++ distributed_game/tests/support/celld.rs | 37 +- distributed_game/tests/support/fleet.rs | 308 ++++ distributed_game/tests/zone_status/main.rs | 152 ++ js/src/game/connection.ts | 6 + js/src/game/index.ts | 1 + js/src/game/messages.ts | 7 + js/tests/game-connection.test.mjs | 20 + scripts/check-wasm32-domains.sh | 13 +- tests/celld/README.md | 17 + tests/celld/zone-worker/.gitignore | 4 + tests/celld/zone-worker/Cargo.toml | 27 + tests/celld/zone-worker/src/lib.rs | 288 ++++ tests/celld/zone-worker/wrangler.jsonc | 29 + 30 files changed, 4176 insertions(+), 92 deletions(-) create mode 100644 distributed_game/src/celld_zone/cells.rs create mode 100644 distributed_game/src/celld_zone/mod.rs create mode 100644 distributed_game/src/celld_zone/object.rs create mode 100644 distributed_game/src/celld_zone/plan.rs create mode 100644 distributed_game/src/world/mutations/save_zone_activity.mutation.graphql create mode 100644 distributed_game/src/world/zone_activity.rs create mode 100644 distributed_game/tests/celld_zone/main.rs create mode 100644 distributed_game/tests/support/fleet.rs create mode 100644 tests/celld/zone-worker/.gitignore create mode 100644 tests/celld/zone-worker/Cargo.toml create mode 100644 tests/celld/zone-worker/src/lib.rs create mode 100644 tests/celld/zone-worker/wrangler.jsonc diff --git a/.github/workflows/integration-celld.yaml b/.github/workflows/integration-celld.yaml index 5f196a20..2e9b3da6 100644 --- a/.github/workflows/integration-celld.yaml +++ b/.github/workflows/integration-celld.yaml @@ -42,6 +42,7 @@ jobs: tests/celld/worker -> tests/celld/worker/target tests/celld/alarm-worker -> tests/celld/alarm-worker/target tests/celld/fact-worker -> tests/celld/fact-worker/target + tests/celld/zone-worker -> tests/celld/zone-worker/target shared-key: celld-storage - uses: actions/setup-node@v4 with: @@ -82,6 +83,13 @@ jobs: run: | cargo test -p distributed_game --features celld,runtime --test celld_zone_state cargo test -p distributed_game --features celld,net,lease --test world + - name: Build the celld zone host test Worker + working-directory: tests/celld/zone-worker + run: worker-build --release + - name: Zones as celld objects (celld dev; 3-node fleet on RustFS, crash, facts exactly once) + env: + CELLD_ZONE_REQUIRED: "1" + run: cargo test -p distributed_game --features celld,net --test celld_zone - name: Retain storage proof results and logs if: always() uses: actions/upload-artifact@v4 diff --git a/Cargo.toml b/Cargo.toml index 9b341917..6fdd0377 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] members = ["distributed_macros", "distributed_cli", "distributed_game", "distributed_game_macros"] -exclude = ["tests/e2e-ui", "tests/celld/worker", "tests/celld/relay-worker", "tests/celld/alarm-worker", "tests/celld/fact-worker", "tests/fixtures/wasm32-native-domain", "examples/game/server"] +exclude = ["tests/e2e-ui", "tests/celld/worker", "tests/celld/relay-worker", "tests/celld/alarm-worker", "tests/celld/fact-worker", "tests/celld/zone-worker", "tests/fixtures/wasm32-native-domain", "examples/game/server"] resolver = "2" [workspace.package] diff --git a/distributed_game/Cargo.toml b/distributed_game/Cargo.toml index 1b101016..198ed9ea 100644 --- a/distributed_game/Cargo.toml +++ b/distributed_game/Cargo.toml @@ -16,9 +16,9 @@ default = [] runtime = ["dep:tokio"] # Adapter that commits facts into a `distributed` AggregateCell. cell = ["dep:distributed", "dep:sha2", "dep:base64"] -# `command_sink::CommandFactSink`: facts dispatched as -# commands through any command host (a local service over any repository, -# or celld cells). +# `command_sink::CommandFactSink` over `CelldCommandHost` / +# `LocalCommandHost` (a local service over any repository, or celld cells +# over HTTP). The sink itself is portable (`cell`). command-host = ["cell", "distributed/graphql"] # celld clients: `CelldFactSink` (the command sink over a remote-only # `CelldCommandHost`), `CellZoneOwnership`, `CellZoneStateStore`. @@ -26,13 +26,16 @@ celld = ["cell", "command-host", "dep:reqwest", "dep:uuid"] # celld worker glue (wasm32 Durable Objects): `world::zone_state::celld_worker`, # the zone-state HTTP surface over a cell and an R2 bucket binding. workers-rs = ["cell", "dep:worker", "distributed/workers-rs"] +# Zones as celld Durable Objects (wasm32): `celld_zone::ZoneObject`, +# `CelldObjectHost` (facts object to object), `celld_zone_object!`. +celld-zone = ["cell", "ticket", "workers-rs", "dep:js-sys", "dep:wasm-bindgen", "dep:wasm-bindgen-futures"] # Per-zone Prometheus metrics (dependency-free text exposition). metrics = [] # Load-test bots (library + `game-bots` binary). bots = ["net", "dep:tokio-tungstenite", "dep:futures-util", "tokio/rt-multi-thread"] # axum WebSocket zone server with signed-ticket admission (+ /metrics). -# HMAC-signed admission tickets (`ticket`) without the axum server: what a -# celld zone host (wasm32) needs to admit players (spike gr-p42). +# HMAC-signed admission tickets (`ticket`) without the axum server (the +# celld zone host admits players with it). ticket = ["dep:hmac", "dep:sha2", "dep:base64"] net = ["runtime", "metrics", "ticket", "dep:axum", "dep:hmac", "dep:sha2", "dep:base64", "tokio/net", "tokio/macros", "tokio/io-util"] # `world::FencedLeaseOwnership`: zone ownership over `distributed::lease`. @@ -53,6 +56,9 @@ futures-util = { version = "0.3", default-features = false, features = ["sink", reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true } uuid = { version = "1", features = ["v7"], optional = true } worker = { version = "0.8", optional = true } +js-sys = { version = "0.3", optional = true } +wasm-bindgen = { version = "0.2", optional = true } +wasm-bindgen-futures = { version = "0.4", optional = true } [dev-dependencies] base64 = "0.23" @@ -121,6 +127,11 @@ name = "celld_facts" path = "tests/celld_facts/main.rs" required-features = ["celld", "runtime"] +[[test]] +name = "celld_zone" +path = "tests/celld_zone/main.rs" +required-features = ["celld", "net"] + [[test]] name = "inbox" path = "tests/inbox/main.rs" diff --git a/distributed_game/README.md b/distributed_game/README.md index 616f1da4..e61e09fc 100644 --- a/distributed_game/README.md +++ b/distributed_game/README.md @@ -26,7 +26,8 @@ the browser, but the client SDK has no rendering dependency. - [Build a zone](#build-a-zone) - [Guides](#guides): game loop · input · what clients see · the client SDK · generated clients · SvelteKit apps · client prediction (Rust → WebAssembly) · shared - logic and data (no TypeScript twins) · saving progress · scaling out · + logic and data (no TypeScript twins) · saving progress · scaling out + (zones as celld objects, the native multi-host server) · live updates · lag compensation · testing and operations · security - [Design decisions](#design-decisions) - [Status and limits](#status-and-limits) @@ -291,7 +292,7 @@ A zone is in one of three **tiers**: Idle forever (a city clock, say) or from `has_pending_work` to stay Hot. - **Dormant**: idle for `dormant_after` (30 s); no ticks. With hibernation on, a Dormant zone is saved and unloaded (see - [Scaling out](#scaling-out-many-zones-many-servers)). + [Scaling out](#scaling-out-zones-as-celld-objects)). `on_tier_changed` tells you about transitions. Timers (`ctx.set_timeout(after, value)`, `ctx.set_interval(every, value)`, @@ -786,7 +787,66 @@ ctx.emit_keyed(killer, RecordKillInput { mob, tier }, &[&format_args!("m{id}"), B" sorts after "left A" even when it commits first or the two hosts' clocks disagree. -### Scaling out: many zones, many servers +### Scaling out: zones as celld objects + +On celld every zone is a Durable Object (feature `celld-zone`, +[`celld_zone`](src/celld_zone/mod.rs)): one object per zone id, so a room +*is* a cell. Game code implements `CelldZone` for its `Zone` and declares +the class; nothing in the game or its API names a server. + +```rust,ignore +impl CelldZone for MobPit { + fn object_config(zone: &str, env: &ObjectEnv) -> Result { /* … */ } + fn fact_routes(sink: ObjectFactSink) -> ObjectFactSink { + sink.route(FactRoute::by_principal::("crawler")) + } +} +distributed_game::celld_zone_object!(PitRoom, MobPit); + +// worker entry: /rooms/{zone} → the zone's object (WebSocket or …/info) +if let Some(response) = forward_to_zone(req, &env, "PIT_ROOM", "/rooms/").await? { … } +``` + +**What celld handles:** where a zone runs (first touch; overflow to the +least-loaded node with `CELLD_MAX_RESIDENT_CELLS`), forwarding a WebSocket +from any node to the owner, one live instance per zone (fenced by its own +epochs), evicting idle objects, moving idle ones to balance load, draining +a stopping node and recovering a crashed node's objects elsewhere. + +**What the framework handles:** the WebSocket join (signed ticket, transfer +ticket of a doorway handoff — single use across activations — or reconnect +token), a self-correcting tick loop that runs only while someone is +connected, the zone's snapshot in object storage (every +`ZONE_PERSIST_MS` while occupied, and when it empties), dormancy and +hibernation (`ZONE_HIBERNATE_AFTER_MS` later, by object alarm, so it also +happens after an eviction), restore on the next join, facts to the cells +of the same worker object to object (`CelldObjectHost` under the usual +`CommandFactSink` routes; a durable outbox in the object's storage makes +them exactly once across crashes), and the zone's lifecycle reports +(`active` / `dormant` / `hibernated`) into its +[`zone_activity`](src/world/zone_activity.rs) cell, which the +`zone_status` read model projects for minimaps. + +**Limits.** A live zone never moves (only idle objects are rebalanced), so +load spreads by first touch and node caps. Objects of one class share V8 +isolates: a slow zone delays its neighbours. A node crash takes about the +node lease TTL (`CELLD_TTL_MS`, 10 s) to recover and loses at most the +snapshot interval of zone state; facts survive in the outbox. A socket +that outlives its activation closes with 1012 and the SDK rejoins with a +fresh ticket. celld does not tell code which node it runs on: the node +shown in debug views comes from an opt-in operator lookup +(`CELLD_ZONE_NODES`), never used for routing. + +Measured on celld 0.6.2 (gr-p42 spike, a loaded 18-core laptop): about 150 +one-player rooms or 4–5 fifty-player rooms per core at 30 Hz; forwarding +through another node adds under a millisecond; a hibernated room wakes in +35–130 ms (p50). + +### Scaling out: the native multi-host server + +The `net` server can also spread zones over several processes itself +(Endless Grid and Boss Ripple use it): placement, ownership and +hibernation are then the framework's job. - **Placement.** A `ZoneDirectory` (`HashRing`) maps each zone id to a host. A client that connects to the wrong host is told where to go @@ -942,7 +1002,10 @@ trait and its ABI (`run_abi`, `call_bytes`). | *(default)* | Zone/host runtime, facts queue, protocol, schema, replication, authoring derives, game surface, predicted steps (compiles to `wasm32-unknown-unknown`, where `#[predict]` exports its steps) | `serde`, `serde_json`, `distributed_game_macros` | | `runtime` | `spawn_fact_worker` on Tokio | `tokio` (rt, time, sync) | | `cell` | `cell::CellFactSink` → in-process `AggregateCell::dispatch_idempotent` (native, unit tests); `cell::fact_session_from_headers` / `session_stamp` for cells (also wasm32); `stamp_event_record`; the `world::zone_lease` and `world::zone_state` cell aggregates | `distributed`, `sha2`, `base64` | -| `command-host` | `command_sink::CommandFactSink`: facts → commands through any command host, routed by command (`FactRoute`) | `cell` + `distributed/graphql` | +| `cell` (also) | `command_sink::CommandFactSink`: facts → commands through any fact host, routed by command (`FactRoute`); `world::zone_activity` | | +| `command-host` | `FactHost` for `CelldCommandHost` / `LocalCommandHost` (`CelldFactSink`) | `cell` + `distributed/graphql` | +| `celld-zone` | `celld_zone`: any `Zone` as a celld Durable Object (`ZoneObject`, `celld_zone_object!`, `forward_to_zone`), facts object to object (`CelldObjectHost`), `serve_cell` / `ZoneActivityCell` for the worker's cells (wasm32) | `cell`, `ticket`, `workers-rs` + `js-sys`, `wasm-bindgen(-futures)` | +| `ticket` | `ticket`: HS256 admission and transfer tickets without the axum server | `hmac`, `sha2`, `base64` | | `celld` | `celld::CelldFactSink` (the command sink over a remote-only `CelldCommandHost`): facts → celld cells over the internal HTTP path; `celld::CellZoneOwnership`: zone leases in celld `ZoneLease` cells; `celld::CellZoneStateStore`: hibernation snapshots in celld `ZoneState` cells | `command-host` + `reqwest`, `uuid` | | `workers-rs` | `world::zone_state::celld_worker`: the `zone-state/{zone}` route of a celld worker (cell + R2 bucket binding for large snapshots) | `cell` + `worker` | | `metrics` | `metrics::GameMetrics` per-zone Prometheus text | – | diff --git a/distributed_game/src/celld_zone/cells.rs b/distributed_game/src/celld_zone/cells.rs new file mode 100644 index 00000000..b03f7bce --- /dev/null +++ b/distributed_game/src/celld_zone/cells.rs @@ -0,0 +1,189 @@ +//! Serving fact cells next to celld zones (wasm32): the internal wait-path +//! route every fact cell of a game worker needs, and the zone-activity cell. + +use distributed::cell_host::{ + AggregateCell, CellCommandIdentity, CellDispatchError, CellWaitPathRequest, CelldOutbox, + InternalHttpSecret, CELL_INTERNAL_SECRET_ENV, CELL_INTERNAL_SECRET_HEADER, + CELL_PRINCIPAL_PARTITION_HEADER, CELL_SERVICE_ID_HEADER, +}; +use distributed::microsvc::Session; +use distributed::Aggregate; +use serde_json::{json, Value}; +use worker::{Env, Method, Request, Response, Result, State}; + +use crate::cell::fact_session_from_headers; +use crate::world::zone_activity::{mount_zone_activity, ZoneActivity}; + +/// Builds the handler session from trusted request headers (after the +/// internal secret was checked); `None` refuses the command. +pub type Sessions = fn(&dyn Fn(&str) -> Option) -> Option; + +/// Facts only: the principal and the `x-game-*` stamp +/// ([`fact_session_from_headers`]). +pub fn fact_sessions(header: &dyn Fn(&str) -> Option) -> Option { + fact_session_from_headers(header) +} + +/// Check `x-distributed-internal-secret` against +/// `DISTRIBUTED_INTERNAL_SECRET`. +pub fn authenticate(req: &Request, env: &Env) -> std::result::Result<(), CellDispatchError> { + let configured = env + .secret(CELL_INTERNAL_SECRET_ENV) + .map(|value| value.to_string()) + .or_else(|_| { + env.var(CELL_INTERNAL_SECRET_ENV) + .map(|value| value.to_string()) + }) + .map_err(|_| CellDispatchError::Internal("internal secret is required".into()))?; + let secret = InternalHttpSecret::new(configured).map_err(CellDispatchError::Internal)?; + let candidate = req + .headers() + .get(CELL_INTERNAL_SECRET_HEADER) + .ok() + .flatten() + .ok_or(CellDispatchError::Unauthorized)?; + if secret.matches(&candidate) { + Ok(()) + } else { + Err(CellDispatchError::Unauthorized) + } +} + +/// A JSON response with `status`. +pub fn json_status(body: Value, status: u16) -> Result { + Ok(Response::from_json(&body)?.with_status(status)) +} + +/// The wire form of a dispatch error. +pub fn error_response(error: CellDispatchError) -> Result { + json_status( + json!({ "error": error.client_message(), "code": error.code() }), + error.status_code(), + ) +} + +/// One cell's internal routes: `GET /{kind}/{shard}` → `read(cell)`, `POST +/// /{kind}/{shard}/{command}` → the idempotent wait-path dispatch (identity +/// from the service-id and principal-partition headers, the session from +/// `sessions`), then a best-effort outbox drain. Every request must carry +/// the internal secret. +pub async fn serve_cell( + env: &Env, + cell: &AggregateCell, + mut req: Request, + read: F, + sessions: Sessions, +) -> Result +where + A: Aggregate + Send + Sync + 'static, + F: AsyncFnOnce(&AggregateCell) -> std::result::Result, +{ + if let Err(error) = authenticate(&req, env) { + return error_response(error); + } + let url = req.url()?; + let parts: Vec = url + .path() + .split('/') + .filter(|part| !part.is_empty()) + .map(str::to_string) + .collect(); + match (req.method(), parts.get(2)) { + (Method::Get, None) => match read(cell).await { + Ok(body) => json_status(body, 200), + Err(error) => error_response(CellDispatchError::Internal(error)), + }, + (Method::Post, Some(command)) => { + let command = command.clone(); + let body: Value = match req.json().await { + Ok(body) => body, + Err(_) => { + return error_response(CellDispatchError::BadRequest("invalid JSON".into())) + } + }; + let parsed = match CellWaitPathRequest::parse(body) { + Ok(parsed) => parsed, + Err(error) => return error_response(CellDispatchError::BadRequest(error)), + }; + let header = |name: &str| req.headers().get(name).ok().flatten(); + let identity = match ( + header(CELL_SERVICE_ID_HEADER), + header(CELL_PRINCIPAL_PARTITION_HEADER), + ) { + (Some(service), Some(partition)) => { + match CellCommandIdentity::new(service, partition, &parsed.command_id) { + Ok(identity) => identity, + Err(error) => return error_response(error), + } + } + _ => return error_response(CellDispatchError::Unauthorized), + }; + let Some(session) = sessions(&header) else { + return error_response(CellDispatchError::Forbidden); + }; + let result = cell + .dispatch_idempotent(&command, &identity, parsed.input, session) + .await; + if let Err(error) = cell.drain_outbox(env).await { + worker::console_error!("post-commit drain deferred: {error}"); + } + match result { + Ok(dispatch) => json_status(dispatch.wait_path_wire(), 200), + Err(error) => error_response(error), + } + } + _ => json_status(json!({ "error": "not found" }), 404), + } +} + +/// A cell's alarm: due schedules, then the outbox drain. +pub async fn cell_alarm(env: &Env, cell: &AggregateCell) -> Result +where + A: Aggregate + Send + Sync + 'static, +{ + let wake = cell + .alarm(env) + .await + .map_err(|error| worker::Error::RustError(error.to_string()))?; + for error in &wake.drain.deferred { + worker::console_error!("drain deferred: {error}"); + } + Response::ok("ok") +} + +/// The zone-activity cell of a game worker (`ZoneActivity:{zone}`, kind +/// `zone-activity`, binding `ZONE_ACTIVITY`): the zone's own lifecycle +/// facts; its `zone.activity_changed` events leave through `outbox` (the +/// worker's Queue) for the zone status read model. `GET` returns the row. +pub struct ZoneActivityCell { + cell: AggregateCell, + env: Env, +} + +impl ZoneActivityCell { + /// Open the cell of `state`, publishing its events to `outbox` (the + /// worker's Queue producer binding; cells commit only with one). + pub fn new(state: State, env: Env, outbox: CelldOutbox) -> Self { + let cell = mount_zone_activity( + AggregateCell::::from_state_with_snapshots(state, 20) + .expect("zone activity cell identity"), + ) + .with_celld_outbox(outbox); + Self { cell, env } + } + + /// Durable Object `fetch`. + pub async fn fetch(&self, req: Request) -> Result { + serve_cell(&self.env, &self.cell, req, read_activity, fact_sessions).await + } + + /// Durable Object `alarm`. + pub async fn alarm(&self) -> Result { + cell_alarm(&self.env, &self.cell).await + } +} + +async fn read_activity(cell: &AggregateCell) -> std::result::Result { + let activity = cell.load().await.map_err(|error| error.to_string())?; + Ok(json!({ "activity": activity.map(|a| a.row()) })) +} diff --git a/distributed_game/src/celld_zone/mod.rs b/distributed_game/src/celld_zone/mod.rs new file mode 100644 index 00000000..e7b50c7a --- /dev/null +++ b/distributed_game/src/celld_zone/mod.rs @@ -0,0 +1,85 @@ +//! Zones as celld Durable Objects (feature `celld-zone`): host any [`Zone`] +//! as one object per zone id. celld decides where a zone runs, forwards +//! every connection to it, moves idle zones, evicts sleeping ones and +//! recovers them after a node crash; game code and game APIs never name a +//! server. +//! +//! ```text +//! client ──WebSocket /rooms/{zone}──▶ any celld node ──forward──▶ the zone's object +//! │ ZoneHost (this activation) +//! Hello(ticket | transfer ticket | reconnect token) ┤ deadline tick loop while occupied +//! │ snapshot every few s + when empty +//! facts (outbox in object storage) ───┼──▶ cell objects (CommandFactSink +//! │ over CelldObjectHost, exactly once) +//! zone_activity.record (active / dormant / hibernated)┘──▶ zone-activity/{zone} cell +//! └ outbox ▶ Queue ▶ zone_status read model +//! ``` +//! +//! What the framework does per object ([`ZoneObject`], wasm32): +//! - **Join.** A WebSocket upgrade is accepted as a hibernatable socket; +//! its attachment holds the client id and the activation id. Hello is +//! verified with the shared HS256 [`TicketKey`](crate::ticket::TicketKey) +//! (env `GAME_TICKET_SECRET`): a join ticket, a transfer ticket from a +//! neighbouring zone's handoff (single use, also across activations) or a +//! reconnect token inside the grace window. +//! - **Ticks.** A self-correcting `setTimeout` loop over absolute deadlines +//! ([`TickSchedule`]; `setInterval` drifts) drives +//! [`ZoneHost::advance`](crate::ZoneHost::advance) while anyone is +//! connected, and stops when the zone is empty. +//! - **State.** The zone's [`Zone::hibernate`] snapshot is written to object +//! storage every `ZONE_PERSIST_MS` while occupied and when the last +//! player leaves (`dormant`); after `ZONE_HIBERNATE_AFTER_MS` empty (an +//! object alarm, so it survives eviction) the zone is dropped from memory +//! (`hibernated`). The next join wakes it and restores the snapshot. +//! - **Restarts.** A socket that outlives its activation (eviction, a move, +//! a crash) is closed with [`CLOSE_RESTARTED`] (1012); the client rejoins +//! with a fresh ticket. +//! - **Facts.** Zone facts go to cells of the same worker object to object +//! ([`CelldObjectHost`] under [`CommandFactSink`](crate::command_sink::CommandFactSink), +//! the same routes and command ids as over HTTP). Each fact is written to +//! 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. +//! - **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). +//! +//! What celld does: placement (first touch, overflow by +//! `CELLD_MAX_RESIDENT_CELLS`), forwarding from any node, idle eviction, +//! rebalancing of idle objects, graceful drain and crash recovery (fenced +//! by its own epochs; one instance per object name). +//! +//! Limits: a live zone never moves (only idle ones are rebalanced), so +//! spreading load depends on first touch and node caps; objects of one +//! class share V8 isolates (a slow zone delays its neighbours); a crash +//! 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. +//! +//! 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. +//! Both are off by default; nothing routes by them. + +pub mod plan; + +#[cfg(target_arch = "wasm32")] +mod cells; +#[cfg(target_arch = "wasm32")] +mod object; + +pub use plan::{ + cell_binding, ObjectOptions, RoomState, StoredFact, TickSchedule, CLOSE_RESTARTED, ZONE_HEADER, +}; + +#[cfg(target_arch = "wasm32")] +pub use cells::{ + authenticate, cell_alarm, error_response, fact_sessions, json_status, serve_cell, Sessions, + ZoneActivityCell, +}; +#[cfg(target_arch = "wasm32")] +pub use object::{ + forward_to_zone, CelldObjectHost, CelldZone, LocalFuture, ObjectEnv, ObjectFactSink, ZoneObject, +}; + +#[cfg(doc)] +use crate::Zone; diff --git a/distributed_game/src/celld_zone/object.rs b/distributed_game/src/celld_zone/object.rs new file mode 100644 index 00000000..3180fc9c --- /dev/null +++ b/distributed_game/src/celld_zone/object.rs @@ -0,0 +1,1407 @@ +//! The Durable Object side of the celld zone host (wasm32 only). + +use std::cell::RefCell; +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::rc::Rc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use distributed::cell_host::{ + CELL_INTERNAL_SECRET_ENV, CELL_INTERNAL_SECRET_HEADER, CELL_PRINCIPAL_PARTITION_HEADER, + CELL_SERVICE_ID_HEADER, +}; +use distributed::USER_ID_KEY; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use worker::send::{SendFuture, SendWrapper}; +use worker::{ + Delay, Env, Headers, Method, Request, RequestInit, Response, Result, State, Storage, WebSocket, + WebSocketIncomingMessage, WebSocketPair, +}; + +use super::plan::{ + cell_binding, classify_wait_path, zone_from_path, ObjectOptions, RoomState, StoredFact, + TickSchedule, Window, CLOSE_RESTARTED, OUTBOX_PREFIX, ZONE_HEADER, +}; +use crate::command_sink::{CommandFactSink, FactCall, FactHost, FactRoute}; +use crate::facts::{ + fact_channel, BoxFuture, Fact, FactError, FactQueue, FactReceipt, FactSink, RetryPolicy, +}; +use crate::handoff::{HandoffRequest, Transfer}; +use crate::inbox::ZoneInbound; +use crate::protocol::{error, ClientMessage, Role, ServerMessage, PROTOCOL_VERSION}; +use crate::stamp::FactStamp; +use crate::ticket::{NonceCache, TicketKey, VerifiedTicket}; +use crate::world::zone_activity::{ + zone_principal, RecordActivity, ACTIVITY_ACTIVE, ACTIVITY_DORMANT, ACTIVITY_HIBERNATED, + ZONE_ACTIVITY_KIND, +}; +use crate::{ClientId, Clock, HostConfig, Outbound, TicketClaims, Zone, ZoneHost}; + +/// A future that runs on the object's (single) thread. +pub type LocalFuture = Pin + 'static>>; + +/// The zone's fact sink on celld: commands to cells, object to object. +pub type ObjectFactSink = CommandFactSink; + +const SNAPSHOT_KEY: &str = "zone:snapshot"; +const STATUS_KEY: &str = "zone:status"; +const NONCE_PREFIX: &str = "nonce:"; +/// Transfer tickets (doorways) live this long. +const TRANSFER_TTL_SECS: u64 = 30; +/// Unsettled facts one activation may hold. +const FACT_CAPACITY: usize = 512; +/// Retry stored facts this often while any is unsettled (ms). +const OUTBOX_RETRY_MS: f64 = 10_000.0; + +/// 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). +pub trait CelldZone: Zone { + /// The zone's construction parameters for `zone_id` (validate the id + /// here: `Err` refuses the join). + fn object_config(zone_id: &str, env: &ObjectEnv) -> std::result::Result; + + /// Host tuning; the object sets the tick and patch rates, reconnect + /// grace and boot epoch from its options. Default: [`HostConfig`] + /// defaults. + fn host_config(_zone_id: &str, _env: &ObjectEnv) -> HostConfig { + HostConfig::default() + } + + /// Fact routes of the zone (lifecycle facts too, if the host config + /// emits them). The zone-activity route is added by the framework. + fn fact_routes(sink: ObjectFactSink) -> ObjectFactSink { + sink + } + + /// Async work after a player joined (e.g. read the player's profile + /// from its cell); the message it returns is delivered into the zone + /// ([`Zone::on_message`]). Runs off the tick; the zone must not wait + /// for it. + fn after_join( + _env: &ObjectEnv, + _principal: &str, + ) -> Option>> { + None + } +} + +// ------------------------------------------------------------------ env + +/// The object's environment for game hooks: vars and cell reads. +#[derive(Clone)] +pub struct ObjectEnv { + env: Env, +} + +impl ObjectEnv { + /// Wrap a worker env. + pub fn new(env: Env) -> Self { + Self { env } + } + + /// A var or secret. + pub fn var(&self, name: &str) -> Option { + self.env + .var(name) + .map(|v| v.to_string()) + .or_else(|_| self.env.secret(name).map(|v| v.to_string())) + .ok() + } + + /// The worker env. + pub fn env(&self) -> &Env { + &self.env + } + + /// `GET /{kind}/{shard}` on a cell of this worker (its read route), + /// object to object, with the internal secret. + pub async fn read_cell(&self, kind: &str, shard: &str) -> std::result::Result { + let secret = self + .var(CELL_INTERNAL_SECRET_ENV) + .ok_or_else(|| format!("{CELL_INTERNAL_SECRET_ENV} is required"))?; + let headers = Headers::new(); + headers + .set(CELL_INTERNAL_SECRET_HEADER, &secret) + .map_err(|e| e.to_string())?; + let mut init = RequestInit::new(); + init.with_method(Method::Get).with_headers(headers); + let url = format!("https://cell/{kind}/{}", encode_segment(shard)); + let request = Request::new_with_init(&url, &init).map_err(|e| e.to_string())?; + let stub = stub_for(&self.env, kind, shard).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(); + let body: Value = response.json().await.unwrap_or(Value::Null); + if (200..300).contains(&status) { + Ok(body) + } else { + Err(format!("HTTP {status}: {body}")) + } + } +} + +/// The stub of cell `kind/shard` (binding [`cell_binding`]`(kind)`). +fn stub_for(env: &Env, kind: &str, shard: &str) -> Result { + let namespace = env.durable_object(&cell_binding(kind))?; + let id = namespace.id_from_name(shard)?; + id.get_stub() +} + +fn encode_segment(segment: &str) -> String { + let mut encoded = String::with_capacity(segment.len()); + for byte in segment.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~' | b':') { + encoded.push(byte as char); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +// ------------------------------------------------------------------ fact host + +/// [`FactHost`] over the cells of the same worker: the cell wait-path +/// request (`POST /{kind}/{shard}/{command}`, internal secret, principal, +/// `x-game-*` stamp) sent straight to the cell's Durable Object stub — +/// object to object, no HTTP hop through a node's public listener. The +/// binding of kind `k` is [`cell_binding`]`(k)`. +pub struct CelldObjectHost { + env: SendWrapper, + secret: String, + service_id: String, +} + +impl CelldObjectHost { + /// A host for `env`'s cells under command-ledger `service_id`. + pub fn new(env: Env, secret: impl Into, service_id: impl Into) -> Self { + Self { + env: SendWrapper::new(env), + secret: secret.into(), + service_id: service_id.into(), + } + } + + async fn post(&self, call: &FactCall<'_>) -> std::result::Result { + let retry = |e: worker::Error| FactError::Retryable(e.to_string()); + let headers = Headers::new(); + let set = |name: &str, value: &str| { + headers + .set(name, value) + .map_err(|e| FactError::Permanent(format!("header {name}: {e}"))) + }; + set(CELL_INTERNAL_SECRET_HEADER, &self.secret)?; + set(CELL_SERVICE_ID_HEADER, &self.service_id)?; + set(CELL_PRINCIPAL_PARTITION_HEADER, &call.principal)?; + set(USER_ID_KEY, &call.principal)?; + set("content-type", "application/json")?; + for (name, value) in call.session.variables() { + if name.starts_with(crate::stamp::SESSION_PREFIX) { + set(name, value)?; + } + } + let body = json!({ "commandId": call.command_id, "input": call.input }).to_string(); + let mut init = RequestInit::new(); + init.with_method(Method::Post) + .with_headers(headers) + .with_body(Some(body.into())); + let url = format!( + "https://cell/{}/{}/{}", + encode_segment(call.kind), + encode_segment(&call.shard), + encode_segment(call.command) + ); + let request = Request::new_with_init(&url, &init).map_err(retry)?; + let stub = stub_for(&self.env, call.kind, &call.shard) + .map_err(|e| FactError::Permanent(format!("cell `{}`: {e}", call.kind)))?; + let mut response = stub.fetch_with_request(request).await.map_err(retry)?; + let status = response.status_code(); + let body: Value = response.json().await.unwrap_or(Value::Null); + classify_wait_path(status, &body) + } +} + +impl FactHost for CelldObjectHost { + fn dispatch_fact<'a>( + &'a self, + call: FactCall<'a>, + ) -> BoxFuture<'a, std::result::Result> { + Box::pin(SendFuture::new(async move { self.post(&call).await })) + } +} + +/// The durable fact outbox around the sink: every emitted fact is written +/// to object storage right after the call that emitted it (from the fact +/// queues' journals, [`FactQueue::with_journal`]) and deleted here once it +/// settled (committed or permanently refused). The next activation (or the +/// outbox alarm) resends what is left; the cells' command ledger makes a +/// resend a replay. +#[derive(Clone)] +struct DurableFacts { + sink: ObjectFactSink, + storage: SendWrapper>, +} + +impl FactSink for DurableFacts { + fn commit<'a>( + &'a self, + fact: &'a Fact, + ) -> BoxFuture<'a, std::result::Result> { + Box::pin(SendFuture::new(async move { + let result = self.sink.commit(fact).await; + match &result { + Err(FactError::Permanent(e)) => { + worker::console_error!("fact {} refused: {e}", fact.key()) + } + Err(FactError::Retryable(e)) => { + worker::console_warn!("fact {} will be retried: {e}", fact.key()) + } + Ok(_) => {} + } + if !matches!(result, Err(FactError::Retryable(_))) { + let _ = self + .storage + .delete(&StoredFact::storage_key(fact.key())) + .await; + } + result + })) + } +} + +// ------------------------------------------------------------------ time + +fn now_ms() -> f64 { + js_sys::Date::now() +} + +/// Monotonic host clock over `Date.now()` (`Instant` panics on +/// wasm32-unknown-unknown). +struct JsClock { + origin_ms: f64, + last_us: AtomicU64, +} + +impl JsClock { + fn new() -> Self { + Self { + origin_ms: now_ms(), + last_us: AtomicU64::new(0), + } + } +} + +impl Clock for JsClock { + fn now(&self) -> Duration { + let us = ((now_ms() - self.origin_ms).max(0.0) * 1000.0) as u64; + Duration::from_micros(self.last_us.fetch_max(us, Ordering::Relaxed).max(us)) + } + + fn unix_ms(&self) -> u64 { + self.origin_ms as u64 + self.now().as_millis() as u64 + } +} + +/// `n` random bytes as hex, from `crypto.getRandomValues`. +fn random_hex(n: usize) -> String { + let bytes = js_sys::Uint8Array::new_with_length(n as u32); + let crypto = js_sys::Reflect::get(&js_sys::global(), &"crypto".into()).ok(); + let filled = crypto.and_then(|crypto| { + let get = js_sys::Reflect::get(&crypto, &"getRandomValues".into()).ok()?; + let get: js_sys::Function = get.into(); + get.call1(&crypto, &bytes).ok() + }); + if filled.is_none() { + for i in 0..n { + bytes.set_index(i as u32, (js_sys::Math::random() * 256.0) as u8); + } + } + bytes.to_vec().iter().map(|b| format!("{b:02x}")).collect() +} + +async fn yield_now() { + let _ = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::resolve( + &wasm_bindgen::JsValue::NULL, + )) + .await; +} + +thread_local! { + /// One id per V8 isolate (module instance), for the debug info. + static ISOLATE: String = random_hex(4); +} + +// ------------------------------------------------------------------ object + +#[derive(Serialize, Deserialize)] +struct Attachment { + client: u32, + activation: String, +} + +#[derive(Serialize, Deserialize)] +struct Snapshot { + version: String, + state: String, +} + +#[derive(Default)] +struct TickStats { + late: Window, + work: Window, + dropped: u64, + iterations: u64, +} + +struct Inner { + zone_id: String, + activation: String, + activation_epoch: u64, + options: Option, + key: Option, + host: Option>, + restored: bool, + wakes: u64, + state: RoomState, + sockets: HashMap, + ticking: bool, + nonces: NonceCache, + sink: Option, + activity: Option, + activity_seq: u64, + outbox_loaded: bool, + last_status: String, + node: String, + stats: TickStats, + last_persist_ms: f64, + spooled: u64, +} + +impl Inner { + fn occupied(&self) -> bool { + self.host + .as_ref() + .is_some_and(|h| h.player_count() > 0 || h.client_count() > 0) + } + + fn flush(&mut self) { + let Some(host) = self.host.as_mut() else { + return; + }; + for out in host.drain_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")); + } + } + } + } + } + + /// Facts emitted since the last call (zone and activity queues). + fn emitted(&mut self) -> Vec { + let mut facts = self + .host + .as_mut() + .map(ZoneHost::take_emitted_facts) + .unwrap_or_default(); + if let Some(queue) = self.activity.as_mut() { + facts.extend(queue.take_journal()); + } + facts + } + + fn snapshot(&self) -> Option { + let host = self.host.as_ref()?; + let state = host.hibernate_state()?; + use base64::Engine as _; + Some(Snapshot { + version: host.config().content_version.clone(), + state: base64::engine::general_purpose::STANDARD.encode(state), + }) + } + + /// Queue an activity report (a zone fact into its zone-activity cell). + fn report(&mut self, status: &str) { + if let Some(queue) = self.activity.as_mut() { + queue.drain(); + } + let Some(queue) = self.activity.as_mut() else { + return; + }; + self.activity_seq += 1; + let input = RecordActivity { + status: status.to_string(), + activation: self.activation.clone(), + restored: self.restored, + players: self.host.as_ref().map_or(0, |h| h.player_count() as u32), + node: self.node.clone(), + isolate: ISOLATE.with(|i| i.clone()), + }; + let stamp = FactStamp { + zone_id: self.zone_id.clone(), + tick: self.host.as_ref().map_or(0, |h| h.tick()), + emitted_at_ms: now_ms() as u64, + activation: self.activation_epoch, + seq: self.activity_seq, + ..FactStamp::default() + }; + let key = format!( + "zone_activity.record:{}:{}:{}", + self.zone_id, self.activation_epoch, self.activity_seq + ); + match serde_json::to_value(&input) { + Ok(value) => { + let fact = Fact::trusted( + key, + crate::world::zone_activity::RECORD, + value, + Some(zone_principal(&self.zone_id)), + stamp, + ); + if let Err(e) = queue.try_send(fact) { + worker::console_error!("zone {}: activity report dropped: {e:?}", self.zone_id); + } + } + Err(e) => worker::console_error!("zone activity encode: {e}"), + } + self.last_status = status.to_string(); + } +} + +/// Hosts one [`CelldZone`] as a celld Durable Object. The game's +/// `#[durable_object]` delegates to it (see [`crate::celld_zone_object!`]). +/// +/// Routes (on the object, via [`forward_to_zone`]): a WebSocket upgrade +/// joins the zone (the `distributed_game` wire protocol: Hello with a +/// ticket, a transfer ticket or a reconnect token); `GET …/info` returns +/// debug JSON (activation, node, isolate, tick timing) without waking a +/// hibernated zone. +pub struct ZoneObject { + state: Rc, + env: ObjectEnv, + inner: Rc>>, +} + +impl ZoneObject { + /// The object for `state` (constructor of the Durable Object). + pub fn new(state: State, env: Env) -> Self { + let zone_id = state.id().name().unwrap_or_default(); + let env = ObjectEnv::new(env); + let options = ObjectOptions::from_vars(|name| env.var(name)); + if let Err(e) = &options { + worker::console_error!("celld zone: {e}"); + } + let options = options.ok(); + let key = options + .as_ref() + .map(|o| TicketKey::new(o.ticket_secret.clone().into_bytes())); + let node = options + .as_ref() + .and_then(|o| o.node.clone()) + .unwrap_or_default(); + let inner = Inner { + zone_id, + activation: random_hex(6), + activation_epoch: (now_ms() * 1000.0) as u64, + options, + key, + host: None, + restored: false, + wakes: 0, + state: RoomState::Cold, + sockets: HashMap::new(), + ticking: false, + nonces: NonceCache::default(), + sink: None, + activity: None, + activity_seq: 0, + outbox_loaded: false, + last_status: String::new(), + node, + stats: TickStats::default(), + last_persist_ms: 0.0, + spooled: 0, + }; + Self { + state: Rc::new(state), + env, + inner: Rc::new(RefCell::new(inner)), + } + } + + /// The game hook environment. + pub fn env(&self) -> &ObjectEnv { + &self.env + } + + /// Durable Object `fetch`. + pub async fn fetch(&self, req: Request) -> Result { + if let Some(zone) = req.headers().get(ZONE_HEADER)? { + let mut inner = self.inner.borrow_mut(); + if inner.zone_id.is_empty() { + inner.zone_id = zone; + } + } + if self.inner.borrow().zone_id.is_empty() { + return Response::error("zone id required", 400); + } + let path = req.url()?.path().to_string(); + if path.ends_with("/info") { + return Response::from_json(&self.info()); + } + if req.headers().get("upgrade")?.as_deref() != Some("websocket") { + return Response::error("expected a WebSocket upgrade", 426); + } + if self.inner.borrow().options.is_none() { + return Response::error("zone host is not configured", 503); + } + self.wake().await?; + let pair = WebSocketPair::new()?; + self.state.accept_web_socket(&pair.server); + Response::from_websocket(pair.client) + } + + /// Durable Object `websocket_message`. + pub async fn websocket_message( + &self, + ws: WebSocket, + message: WebSocketIncomingMessage, + ) -> Result<()> { + let WebSocketIncomingMessage::Binary(bytes) = message else { + let _ = ws.close(Some(1003), Some("binary frames only")); + return Ok(()); + }; + let attached: Option = ws.deserialize_attachment().ok().flatten(); + let activation = self.inner.borrow().activation.clone(); + let client = match attached { + Some(a) if a.activation == activation => Some(a.client), + // The socket outlived the activation that admitted it. + Some(_) => { + let _ = ws.close(Some(CLOSE_RESTARTED), Some("zone restarted; rejoin")); + return Ok(()); + } + None => None, + }; + let msg = match ClientMessage::decode(&bytes) { + Ok(m) => m, + Err(_) => { + reject(&ws, error::BAD_MESSAGE, "bad message"); + return Ok(()); + } + }; + match (client, msg) { + (None, ClientMessage::Hello(hello)) => self.hello(&ws, hello).await?, + ( + Some(client), + ClientMessage::Input { + seq, + client_tick, + payload, + }, + ) => { + 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")); + } + } + } + (_, ClientMessage::Ping { client_time }) => { + let server_time = self + .inner + .borrow() + .host + .as_ref() + .map_or(0.0, |h| h.server_time_ms()); + if let Ok(pong) = (ServerMessage::Pong { + client_time, + server_time, + }) + .encode(&()) + { + let _ = ws.send_with_bytes(pong); + } + } + (Some(client), ClientMessage::Leave) => { + { + let mut inner = self.inner.borrow_mut(); + inner.sockets.remove(&client); + if let Some(host) = inner.host.as_mut() { + host.disconnect(ClientId(client), true); + } + inner.flush(); + } + self.spool().await; + let _ = ws.close(Some(1000), Some("left")); + } + _ => reject(&ws, error::BAD_MESSAGE, "unexpected message"), + } + Ok(()) + } + + /// Durable Object `websocket_close`. + pub async fn websocket_close( + &self, + ws: WebSocket, + _code: usize, + _reason: String, + _was_clean: bool, + ) -> Result<()> { + self.drop_socket(&ws); + Ok(()) + } + + /// Durable Object `websocket_error`. + pub async fn websocket_error(&self, ws: WebSocket, _error: worker::Error) -> Result<()> { + self.drop_socket(&ws); + Ok(()) + } + + /// Durable Object `alarm`: hibernate a room that stayed empty, resend + /// unsettled facts, and after a crash mark the room asleep. + pub async fn alarm(&self) -> Result { + if self.inner.borrow().options.is_none() { + return Response::ok("unconfigured"); + } + self.load_zone_id().await; + self.start_facts().await; + let (cold, due, hibernate_after) = { + let inner = self.inner.borrow(); + let after = inner + .options + .as_ref() + .map_or(Duration::from_secs(10), |o| o.hibernate_after); + ( + inner.host.is_none(), + !inner.occupied() && inner.state.hibernate_due(now_ms(), after), + after, + ) + }; + if due { + self.hibernate().await; + } else if cold { + // A fresh activation woken by its alarm: the last one crashed or + // was evicted while awake. Its snapshot is what it left. + let stored: Option = self.state.storage().get(STATUS_KEY).await.ok().flatten(); + if stored.as_deref().is_some_and(|s| s != ACTIVITY_HIBERNATED) { + self.inner.borrow_mut().report(ACTIVITY_HIBERNATED); + self.store_status().await; + } + } else { + let since = match self.inner.borrow().state { + RoomState::Dormant { since_ms } => Some(since_ms), + _ => None, + }; + if let Some(since) = since { + let wait = since + hibernate_after.as_millis() as f64 - now_ms(); + self.arm(wait.max(10.0)).await; + } + } + self.arm_outbox().await; + Response::ok("ok") + } + + // -------------------------------------------------------------- internals + + /// Sockets accepted before an eviction reach a new instance without a + /// zone id from a request; the id is kept in storage. + async fn load_zone_id(&self) { + if !self.inner.borrow().zone_id.is_empty() { + return; + } + if let Ok(Some(zone)) = self.state.storage().get::("zone:id").await { + let mut inner = self.inner.borrow_mut(); + if inner.zone_id.is_empty() { + inner.zone_id = zone; + } + } + } + + /// Start the fact pipeline once per activation and resend what the + /// previous activation left in the outbox. + async fn start_facts(&self) { + if self.inner.borrow().sink.is_some() { + return; + } + let (zone_id, service, secret) = { + let inner = self.inner.borrow(); + let Some(options) = inner.options.as_ref() else { + return; + }; + ( + inner.zone_id.clone(), + options.fact_service.clone(), + options.internal_secret.clone(), + ) + }; + let Some(secret) = secret else { + worker::console_error!( + "zone {zone_id}: {CELL_INTERNAL_SECRET_ENV} is not set; facts are disabled" + ); + return; + }; + let host = CelldObjectHost::new(self.env.env().clone(), secret, &service); + let sink = match ObjectFactSink::new(host, service, zone_principal(&zone_id)) { + Ok(sink) => { + Z::fact_routes(sink.route(FactRoute::by_zone::(ZONE_ACTIVITY_KIND))) + } + Err(e) => { + worker::console_error!("zone {zone_id}: fact sink: {e}"); + return; + } + }; + let durable = DurableFacts { + sink, + storage: SendWrapper::new(Rc::new(self.state.storage())), + }; + let (queue, feed) = fact_channel(FACT_CAPACITY, retry_policy()); + let queue = queue.with_journal(); + wasm_bindgen_futures::spawn_local( + feed.with_max_concurrent_commits(4) + .run(durable.clone(), sleep), + ); + { + let mut inner = self.inner.borrow_mut(); + if inner.sink.is_some() { + return; + } + inner.sink = Some(durable); + inner.activity = Some(queue); + } + let _ = self.state.storage().put("zone:id", &zone_id).await; + // Resend the previous activations' unsettled facts. + let stored = self + .state + .storage() + .list_with_options(worker::ListOptions::new().prefix(OUTBOX_PREFIX)) + .await; + let mut inner = self.inner.borrow_mut(); + inner.outbox_loaded = true; + if let Ok(map) = stored { + let Some(queue) = inner.activity.as_mut() else { + return; + }; + for value in map.values().into_iter().flatten() { + let Some(stored) = value + .as_string() + .and_then(|text| serde_json::from_str::(&text).ok()) + else { + continue; + }; + if let Some(fact) = stored.into_fact() { + let _ = queue.try_send(fact); + } + } + // Already stored. + queue.take_journal(); + } + } + + /// Make sure the zone is in memory (restoring its snapshot). + async fn wake(&self) -> Result<()> { + self.start_facts().await; + if self.inner.borrow().host.is_some() { + return Ok(()); + } + let stored: Option = self.state.storage().get(SNAPSHOT_KEY).await.ok().flatten(); + let host = { + let inner = self.inner.borrow(); + if inner.host.is_some() { + return Ok(()); + } + let options = inner + .options + .as_ref() + .ok_or_else(|| worker::Error::RustError("zone host is not configured".into()))?; + let zone_id = inner.zone_id.clone(); + let config = Z::object_config(&zone_id, &self.env).map_err(worker::Error::RustError)?; + let mut host_config = Z::host_config(&zone_id, &self.env); + host_config.tick_rate = options.tick_hz; + host_config.patch_rate = options.patch_hz; + // The object stops ticking when empty instead of idling. + host_config.idle_tick_rate = None; + host_config.reconnect_grace = options.reconnect_grace; + host_config.boot_epoch = (now_ms() * 1000.0) as u64; + let key = inner.key.clone().expect("ticket key with options"); + let issuer_zone = zone_id.clone(); + let generation = inner.activation_epoch; + let mut host = + ZoneHost::::new(zone_id, host_config, Arc::new(JsClock::new()), config) + .with_token_source(|| random_hex(24)) + .with_transfer_issuer(move |request: &HandoffRequest| { + let expires_at = (now_ms() / 1000.0) as u64 + TRANSFER_TTL_SECS; + Some(key.sign_transfer(&Transfer { + claims: TicketClaims { + principal: request.principal.clone(), + zone: request.target_zone.clone(), + expires_at, + nonce: random_hex(16), + role: Role::Player, + name: request.name.clone(), + extensions: request.extensions.clone(), + }, + source_zone: request.source_zone.clone(), + issuer: issuer_zone.clone(), + generation, + carried: request.carried.clone(), + departed_at_ms: request.departed_at_ms, + })) + }); + let (queue, feed) = fact_channel(FACT_CAPACITY, retry_policy()); + if let Some(sink) = inner.sink.clone() { + wasm_bindgen_futures::spawn_local( + feed.with_max_concurrent_commits(4).run(sink, sleep), + ); + host = host.with_facts(queue.with_journal()); + } + let restored = match &stored { + Some(snapshot) => { + use base64::Engine as _; + match base64::engine::general_purpose::STANDARD.decode(&snapshot.state) { + Ok(state) => { + host.restore(&state, &snapshot.version); + true + } + Err(_) => false, + } + } + None => false, + }; + (host, restored) + }; + let (host, restored) = host; + { + let mut inner = self.inner.borrow_mut(); + if inner.host.is_some() { + return Ok(()); + } + inner.host = Some(host); + inner.restored = restored; + inner.wakes += 1; + // Each wake is a new activation: sockets of an earlier one are + // told to rejoin (1012). + if inner.wakes > 1 { + inner.activation = random_hex(6); + } + inner.state = RoomState::Active; + inner.last_persist_ms = now_ms(); + inner.report(ACTIVITY_ACTIVE); + } + self.store_status().await; + self.find_node(); + Ok(()) + } + + 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()); + return Ok(()); + } + let now = (now_ms() / 1000.0) as u64; + // Transfer tickets are single use across activations too. + let mut principal = None; + let joined = if hello.reconnect_token.is_empty() { + let verified = { + let inner = self.inner.borrow(); + let key = inner.key.as_ref().expect("configured"); + key.verify_ticket(&hello.ticket, now) + }; + let verified = match verified { + Ok(v) => v, + Err(e) => { + reject(ws, error::UNAUTHORIZED, &format!("{e:?}")); + return Ok(()); + } + }; + let zone_id = self.inner.borrow().zone_id.clone(); + let (claims, transfer) = match verified { + VerifiedTicket::Join(claims) => (claims, None), + VerifiedTicket::Transfer(transfer) => (transfer.claims.clone(), Some(transfer)), + }; + if claims.zone != zone_id { + reject(ws, error::UNAUTHORIZED, "ticket is for another zone"); + return Ok(()); + } + let replayed = self + .inner + .borrow_mut() + .nonces + .check_and_insert(&claims.nonce, claims.expires_at, now) + .is_err(); + let nonce_key = format!("{NONCE_PREFIX}{}", claims.nonce); + let used_before = transfer.is_some() + && self + .state + .storage() + .get::(&nonce_key) + .await + .ok() + .flatten() + .is_some(); + if replayed || used_before { + reject(ws, error::UNAUTHORIZED, "ticket already used"); + return Ok(()); + } + if transfer.is_some() { + let _ = self + .state + .storage() + .put(&nonce_key, claims.expires_at) + .await; + } + principal = Some(claims.principal.clone()); + let mut inner = self.inner.borrow_mut(); + let host = inner.host.as_mut().expect("woken"); + match &transfer { + Some(transfer) => { + host.join_transfer(transfer, hello.capabilities, &hello.content_version) + } + None => host.join(&claims, hello.capabilities, &hello.content_version), + } + } else { + let mut inner = self.inner.borrow_mut(); + let host = inner.host.as_mut().expect("woken"); + host.reconnect_with_version( + &hello.reconnect_token, + hello.capabilities, + &hello.content_version, + ) + }; + match joined { + Ok(joined) => { + { + let mut inner = self.inner.borrow_mut(); + let activation = inner.activation.clone(); + ws.serialize_attachment(Attachment { + client: joined.client.0, + activation, + })?; + inner.sockets.insert(joined.client.0, ws.clone()); + inner.flush(); + if inner.state != RoomState::Active { + inner.state = RoomState::Active; + inner.report(ACTIVITY_ACTIVE); + } + } + self.store_status().await; + self.start_loop(); + if let Some(principal) = principal { + self.after_join(principal); + } + } + Err(e) => reject(ws, e.code, &e.message), + } + Ok(()) + } + + fn after_join(&self, principal: String) { + let Some(work) = Z::after_join(&self.env, &principal) 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(); + } + // Facts the message produced are written on the next tick. + } + }); + } + + fn drop_socket(&self, ws: &WebSocket) { + let attached: Option = ws.deserialize_attachment().ok().flatten(); + let mut inner = self.inner.borrow_mut(); + if let Some(a) = attached.filter(|a| a.activation == inner.activation) { + inner.sockets.remove(&a.client); + if let Some(host) = inner.host.as_mut() { + host.disconnect(ClientId(a.client), false); + } + inner.flush(); + drop(inner); + self.spool_later(); + } + } + + fn start_loop(&self) { + { + let mut inner = self.inner.borrow_mut(); + if inner.ticking || inner.host.is_none() { + return; + } + inner.ticking = true; + } + let this = self.handle(); + wasm_bindgen_futures::spawn_local(async move { this.tick_loop().await }); + } + + fn handle(&self) -> Self { + Self { + state: self.state.clone(), + env: self.env.clone(), + inner: self.inner.clone(), + } + } + + async fn tick_loop(&self) { + let (hz, persist_every) = { + let inner = self.inner.borrow(); + let options = inner.options.as_ref().expect("configured"); + (options.tick_hz, options.persist_every.as_millis() as f64) + }; + let mut schedule = TickSchedule::new(now_ms(), hz); + self.arm(persist_every * 3.0).await; + loop { + Delay::from(Duration::from_micros( + (schedule.wait_ms(now_ms()) * 1000.0) as u64, + )) + .await; + let woke = now_ms(); + let late = schedule.woke(woke); + let (stop, persist) = { + let mut inner = self.inner.borrow_mut(); + let Some(host) = inner.host.as_mut() else { + inner.ticking = false; + return; + }; + let report = host.advance(); + inner.flush(); + let stats = &mut inner.stats; + stats.iterations += 1; + stats.late.push(late); + stats.work.push(now_ms() - woke); + stats.dropped += u64::from(report.dropped_ticks); + if let Some(queue) = inner.activity.as_mut() { + queue.drain(); + } + let stop = !inner.occupied(); + let persist = stop || woke - inner.last_persist_ms >= persist_every; + if stop { + inner.ticking = false; + } + (stop, persist) + }; + self.spool().await; + if persist { + self.persist().await; + if !stop { + self.arm(persist_every * 3.0).await; + } + } + if stop { + self.go_dormant().await; + return; + } + } + } + + /// Write the zone's snapshot (after the fact outbox writes queued by + /// this tick, so a crash never keeps an outcome without its fact). + async fn persist(&self) { + yield_now().await; + let snapshot = { + let mut inner = self.inner.borrow_mut(); + inner.last_persist_ms = now_ms(); + inner.snapshot() + }; + if let Some(snapshot) = snapshot { + if let Err(e) = self.state.storage().put(SNAPSHOT_KEY, snapshot).await { + worker::console_error!("zone snapshot write failed: {e}"); + } + } + } + + async fn go_dormant(&self) { + let after = { + let mut inner = self.inner.borrow_mut(); + if inner.occupied() { + return; + } + inner.state = RoomState::Dormant { since_ms: now_ms() }; + inner.report(ACTIVITY_DORMANT); + inner + .options + .as_ref() + .map_or(10_000.0, |o| o.hibernate_after.as_millis() as f64) + }; + self.store_status().await; + self.arm(after).await; + } + + async fn hibernate(&self) { + self.persist().await; + { + let mut inner = self.inner.borrow_mut(); + if inner.occupied() { + return; + } + inner.report(ACTIVITY_HIBERNATED); + // Dropped, so the next join restores from storage. + inner.host = None; + inner.state = RoomState::Cold; + inner.ticking = false; + } + self.store_status().await; + } + + /// Write the facts emitted since the last call to the outbox. + async fn spool(&self) { + let facts = self.inner.borrow_mut().emitted(); + if facts.is_empty() { + return; + } + let storage = self.state.storage(); + // One batched write (the storage API takes at most 128 keys). + for chunk in facts.chunks(128) { + let batch = js_sys::Object::new(); + for fact in chunk { + let text = serde_json::to_string(&StoredFact::of(fact)).unwrap_or_default(); + let _ = js_sys::Reflect::set( + &batch, + &StoredFact::storage_key(fact.key()).into(), + &text.into(), + ); + } + if let Err(e) = storage.put_multiple_raw(batch).await { + worker::console_error!("fact outbox write failed: {e}"); + } + } + self.inner.borrow_mut().spooled += facts.len() as u64; + self.arm_outbox().await; + } + + /// [`spool`](Self::spool) from a synchronous handler. + fn spool_later(&self) { + let this = self.handle(); + wasm_bindgen_futures::spawn_local(async move { this.spool().await }); + } + + async fn store_status(&self) { + self.spool().await; + let status = self.inner.borrow().last_status.clone(); + if !status.is_empty() { + let _ = self.state.storage().put(STATUS_KEY, status).await; + } + } + + /// Set the object's alarm `in_ms` from now. + async fn arm(&self, in_ms: f64) { + if let Err(e) = self + .state + .storage() + .set_alarm(Duration::from_millis(in_ms.max(1.0) as u64)) + .await + { + worker::console_error!("zone alarm: {e}"); + } + } + + /// Keep an alarm while unsettled facts wait in the outbox. + async fn arm_outbox(&self) { + let pending = { + let inner = self.inner.borrow(); + inner.activity.as_ref().map_or(0, |q| q.in_flight()) + + inner.host.as_ref().map_or(0, |h| h.facts_in_flight()) + }; + let alarm = self.state.storage().get_alarm().await.ok().flatten(); + if pending > 0 && alarm.is_none() { + self.arm(OUTBOX_RETRY_MS).await; + } + } + + /// Debug only, off by default: ask each configured operator listener + /// whether it holds this object. + fn find_node(&self) { + let (nodes, id) = { + let inner = self.inner.borrow(); + let Some(options) = inner.options.as_ref() else { + return; + }; + if options.nodes.is_empty() || options.node.is_some() { + return; + } + (options.nodes.clone(), self.state.id().to_string()) + }; + let inner = self.inner.clone(); + wasm_bindgen_futures::spawn_local(async move { + for (node, addr) in nodes { + let Ok(url) = format!("http://{addr}/state").parse() else { + continue; + }; + let Ok(mut response) = worker::Fetch::Url(url).send().await else { + continue; + }; + let Ok(state) = response.json::().await else { + continue; + }; + let mine = state["deployment"]["cells"] + .as_object() + .is_some_and(|cells| cells.keys().any(|k| k.ends_with(&id))); + if mine { + let mut inner = inner.borrow_mut(); + inner.node = node; + if inner.state == RoomState::Active { + inner.report(ACTIVITY_ACTIVE); + } + return; + } + } + }); + } + + fn info(&self) -> Value { + let inner = self.inner.borrow(); + let host = inner.host.as_ref(); + let stats = &inner.stats; + json!({ + "zone": inner.zone_id, + "status": inner.last_status, + "awake": host.is_some(), + "activation": inner.activation, + "restored": inner.restored, + "wakes": inner.wakes, + "node": inner.node, + "isolate": ISOLATE.with(|i| i.clone()), + "players": host.map_or(0, |h| h.player_count()), + "tick": host.map_or(0, |h| h.tick()), + "hz": inner.options.as_ref().map_or(0, |o| o.tick_hz), + "ticking": inner.ticking, + "factsInFlight": host.map_or(0, |h| h.facts_in_flight()) + + inner.activity.as_ref().map_or(0, |q| q.in_flight()), + "spooledFacts": inner.spooled, + "iterations": stats.iterations, + "droppedTicks": stats.dropped, + "lateMs": { "p50": stats.late.percentile(0.5), "p99": stats.late.percentile(0.99) }, + "workMs": { "p50": stats.work.percentile(0.5), "p99": stats.work.percentile(0.99) }, + }) + } +} + +fn retry_policy() -> RetryPolicy { + RetryPolicy::until_settled(Duration::from_millis(100), Duration::from_secs(5)) +} + +fn sleep(d: Duration) -> Delay { + Delay::from(d) +} + +fn reject(ws: &WebSocket, code: u32, message: &str) { + if let Ok(frame) = (ServerMessage::Error { + code, + message: message.into(), + }) + .encode(&()) + { + let _ = ws.send_with_bytes(frame); + } + let _ = ws.close(Some(1008), Some(message)); +} + +/// Worker entry helper: route `"{prefix}{zone}[/…]"` to the zone's object +/// in Durable Object namespace `binding` (one object per zone id; celld +/// places it). Returns `None` when the path is not under `prefix`. +pub async fn forward_to_zone( + req: Request, + env: &Env, + binding: &str, + prefix: &str, +) -> Result> { + let path = req.url()?.path().to_string(); + let Some((zone, _)) = zone_from_path(prefix, &path) else { + return Ok(None); + }; + let stub = env + .durable_object(binding)? + .id_from_name(&zone)? + .get_stub()?; + let headers = req.headers().clone(); + headers.set(ZONE_HEADER, &zone)?; + let mut init = RequestInit::new(); + init.with_method(req.method()).with_headers(headers); + let forwarded = Request::new_with_init(req.url()?.as_str(), &init)?; + stub.fetch_with_request(forwarded).await.map(Some) +} + +/// Declare the Durable Object class hosting zone type `$zone`: +/// +/// ```ignore +/// distributed_game::celld_zone_object!(PitRoom, MobPit); +/// ``` +/// +/// The crate needs `worker` as a dependency. Bind the class in +/// `wrangler.jsonc` (a SQLite class) and route to it with +/// [`forward_to_zone`]. +#[macro_export] +macro_rules! celld_zone_object { + ($name:ident, $zone:ty) => { + #[::worker::durable_object] + pub struct $name { + zone: $crate::celld_zone::ZoneObject<$zone>, + } + + impl ::worker::DurableObject for $name { + fn new(state: ::worker::State, env: ::worker::Env) -> Self { + Self { + zone: $crate::celld_zone::ZoneObject::new(state, env), + } + } + + async fn fetch(&self, req: ::worker::Request) -> ::worker::Result<::worker::Response> { + self.zone.fetch(req).await + } + + async fn websocket_message( + &self, + ws: ::worker::WebSocket, + message: ::worker::WebSocketIncomingMessage, + ) -> ::worker::Result<()> { + self.zone.websocket_message(ws, message).await + } + + async fn websocket_close( + &self, + ws: ::worker::WebSocket, + code: usize, + reason: String, + was_clean: bool, + ) -> ::worker::Result<()> { + self.zone.websocket_close(ws, code, reason, was_clean).await + } + + async fn websocket_error( + &self, + ws: ::worker::WebSocket, + error: ::worker::Error, + ) -> ::worker::Result<()> { + self.zone.websocket_error(ws, error).await + } + + async fn alarm(&self) -> ::worker::Result<::worker::Response> { + self.zone.alarm().await + } + } + }; +} diff --git a/distributed_game/src/celld_zone/plan.rs b/distributed_game/src/celld_zone/plan.rs new file mode 100644 index 00000000..c9e4ab28 --- /dev/null +++ b/distributed_game/src/celld_zone/plan.rs @@ -0,0 +1,473 @@ +//! The runtime-free parts of the celld zone host: options, the room's +//! lifecycle states, the self-correcting tick schedule, the durable fact +//! outbox encoding and the cell wait-path answer classification. Compiled +//! on every target so they are unit-tested natively; the Durable Object glue +//! ([`super::ZoneObject`], wasm32) drives them. + +use std::collections::HashMap; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::facts::{Fact, FactError, FactReceipt}; +use crate::stamp::FactStamp; + +/// Env var: HS256 secret admission (and transfer) tickets are signed with. +pub const TICKET_SECRET_VAR: &str = "GAME_TICKET_SECRET"; +/// Env var: tick rate (Hz, default 30). +pub const TICK_HZ_VAR: &str = "ZONE_TICK_HZ"; +/// Env var: patch rate (Hz, default 20, at most the tick rate). +pub const PATCH_HZ_VAR: &str = "ZONE_PATCH_HZ"; +/// Env var: snapshot interval while occupied (ms, default 5000). +pub const PERSIST_MS_VAR: &str = "ZONE_PERSIST_MS"; +/// Env var: how long an empty room stays dormant before it hibernates (ms, +/// default 10000). +pub const HIBERNATE_AFTER_MS_VAR: &str = "ZONE_HIBERNATE_AFTER_MS"; +/// Env var: reconnect grace (ms, default 5000). +pub const RECONNECT_GRACE_MS_VAR: &str = "ZONE_RECONNECT_GRACE_MS"; +/// Env var: command-ledger service id of the zone's facts (default +/// `celld-zone`). +pub const FACT_SERVICE_VAR: &str = "ZONE_FACT_SERVICE"; +/// Env var (debug only, off by default): `name=host:port,…` celld operator +/// listeners asked which node holds the room (celld does not tell +/// application code its node id). +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"; + +/// 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"; + +/// Close code for sockets of an earlier activation (the room restarted or +/// moved): the client rejoins with a fresh ticket. +pub const CLOSE_RESTARTED: u16 = 1012; + +/// Object settings, from the worker's vars and secrets. +#[derive(Debug, Clone, PartialEq)] +pub struct ObjectOptions { + /// Ticket key secret. + pub ticket_secret: String, + /// Internal secret for cell calls (`DISTRIBUTED_INTERNAL_SECRET`). + pub internal_secret: Option, + /// Hot tick rate. + pub tick_hz: u32, + /// Patch rate. + pub patch_hz: u32, + /// Snapshot interval while occupied. + pub persist_every: Duration, + /// Dormant → hibernated. + pub hibernate_after: Duration, + /// Reconnect grace window. + pub reconnect_grace: Duration, + /// Fact command-ledger service id. + pub fact_service: String, + /// Debug node lookup: `(name, operator host:port)`. + pub nodes: Vec<(String, String)>, + /// Debug fixed node name. + pub node: Option, +} + +fn parse( + get: &impl Fn(&str) -> Option, + name: &str, + default: T, +) -> Result { + match get(name).filter(|value| !value.trim().is_empty()) { + None => Ok(default), + Some(value) => value + .trim() + .parse() + .map_err(|_| format!("{name}: not a number: {value}")), + } +} + +impl ObjectOptions { + /// Read the options through `get` (vars, then secrets). + pub fn from_vars(get: impl Fn(&str) -> Option) -> Result { + let ticket_secret = get(TICKET_SECRET_VAR) + .filter(|secret| secret.len() >= 16) + .ok_or_else(|| format!("{TICKET_SECRET_VAR} (>= 16 bytes) is required"))?; + let tick_hz: u32 = parse(&get, TICK_HZ_VAR, 30)?; + if !(1..=120).contains(&tick_hz) { + return Err(format!("{TICK_HZ_VAR} must be within 1..=120")); + } + let patch_hz: u32 = parse(&get, PATCH_HZ_VAR, 20)?; + let nodes = get(NODES_VAR) + .unwrap_or_default() + .split(',') + .filter_map(|entry| { + let (name, addr) = entry.trim().split_once('=')?; + (!name.is_empty() && !addr.is_empty()).then(|| (name.into(), addr.into())) + }) + .collect(); + Ok(Self { + ticket_secret, + internal_secret: get(distributed::cell_host::CELL_INTERNAL_SECRET_ENV) + .filter(|secret| !secret.is_empty()), + tick_hz, + patch_hz: patch_hz.clamp(1, tick_hz), + persist_every: Duration::from_millis(parse(&get, PERSIST_MS_VAR, 5_000)?), + hibernate_after: Duration::from_millis(parse(&get, HIBERNATE_AFTER_MS_VAR, 10_000)?), + reconnect_grace: Duration::from_millis(parse(&get, RECONNECT_GRACE_MS_VAR, 5_000)?), + fact_service: get(FACT_SERVICE_VAR) + .filter(|service| !service.trim().is_empty()) + .unwrap_or_else(|| "celld-zone".into()), + nodes, + node: get(NODE_VAR).filter(|node| !node.is_empty()), + }) + } +} + +/// Where a room is in its life (per object activation). +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum RoomState { + /// No zone in memory (fresh activation, or hibernated and dropped). + Cold, + /// Zone in memory with players (or just woke up). + Active, + /// Empty since `since_ms` (Unix ms); snapshot written. + Dormant { + /// When the last player left. + since_ms: f64, + }, +} + +impl RoomState { + /// True when an empty room should hibernate at `now_ms`. + pub fn hibernate_due(&self, now_ms: f64, after: Duration) -> bool { + match self { + Self::Dormant { since_ms } => now_ms + 1.0 >= since_ms + after.as_millis() as f64, + _ => false, + } + } +} + +/// A self-correcting fixed-rate schedule over absolute deadlines +/// (`setInterval` drifts: 33.3 ms gave 35 ms on celld). Each wake-up aims at +/// `start + k × step`; deadlines already missed are skipped (the fixed +/// timestep catches up inside one `advance`). +#[derive(Debug, Clone)] +pub struct TickSchedule { + start_ms: f64, + step_ms: f64, + k: u64, +} + +impl TickSchedule { + /// A schedule at `hz` from `now_ms`. + pub fn new(now_ms: f64, hz: u32) -> Self { + Self { + start_ms: now_ms, + step_ms: 1000.0 / f64::from(hz.max(1)), + k: 1, + } + } + + /// The next deadline. + pub fn deadline(&self) -> f64 { + self.start_ms + self.k as f64 * self.step_ms + } + + /// How long to wait from `now_ms` (never negative). + pub fn wait_ms(&self, now_ms: f64) -> f64 { + (self.deadline() - now_ms).max(0.0) + } + + /// Record a wake-up at `now_ms`: returns how late it was, and moves to + /// the first deadline after `now_ms`. + pub fn woke(&mut self, now_ms: f64) -> f64 { + let late = (now_ms - self.deadline()).max(0.0); + let next = ((now_ms - self.start_ms) / self.step_ms).floor() as u64 + 1; + self.k = next.max(self.k + 1); + late + } +} + +/// A fact in the object's durable outbox (written before it is sent, +/// deleted once settled, resent by the next activation). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StoredFact { + /// Idempotency key. + pub key: String, + /// Command. + pub command: String, + /// Input. + pub input: Value, + /// Bound principal. + pub principal: Option, + /// Stamp as its `x-game-*` session variables. + pub stamp: HashMap, +} + +impl StoredFact { + /// Storage key of a fact. + pub fn storage_key(key: &str) -> String { + format!("{OUTBOX_PREFIX}{key}") + } + + /// Encode `fact`. + pub fn of(fact: &Fact) -> Self { + Self { + key: fact.key().to_string(), + command: fact.command().to_string(), + input: fact.input().clone(), + principal: fact.principal().map(str::to_string), + stamp: fact.stamp().session_vars().into_iter().collect(), + } + } + + /// Decode (None for a malformed stamp). + pub fn into_fact(self) -> Option { + let stamp = FactStamp::from_session_vars(&self.stamp)?; + Some(Fact::trusted( + self.key, + self.command, + self.input, + self.principal, + stamp, + )) + } +} + +/// Storage key prefix of the durable fact outbox. +pub const OUTBOX_PREFIX: &str = "fact:"; + +/// Durable Object binding of a cell kind: upper case, `-` → `_` +/// (`zone-activity` → `ZONE_ACTIVITY`, `crawler` → `CRAWLER`). +pub fn cell_binding(kind: &str) -> String { + kind.chars() + .map(|c| { + if c == '-' { + '_' + } else { + c.to_ascii_uppercase() + } + }) + .collect() +} + +/// Classify a cell wait-path answer (`status`, JSON `body`). +pub fn classify_wait_path(status: u16, body: &Value) -> Result { + if (200..300).contains(&status) { + return Ok(FactReceipt { + payload: body.get("payload").cloned().unwrap_or(Value::Null), + replayed: body + .pointer("/receipt/replayed") + .and_then(Value::as_bool) + .unwrap_or(false), + }); + } + let code = body.get("code").and_then(Value::as_str).unwrap_or(""); + let message = body + .get("error") + .and_then(Value::as_str) + .unwrap_or("rejected"); + let detail = format!("HTTP {status} {code}: {message}"); + if status >= 500 || matches!(status, 408 | 429) || code == "COMMAND_IN_PROGRESS" { + Err(FactError::Retryable(detail)) + } else { + Err(FactError::Permanent(detail)) + } +} + +/// `"{prefix}{zone}[/rest]"` → `(zone, rest)` (percent-decoded zone; None +/// when the path does not match or the zone is empty / not printable). +pub fn zone_from_path<'a>(prefix: &str, path: &'a str) -> Option<(String, &'a str)> { + let rest = path.strip_prefix(prefix)?; + let (raw, tail) = rest.split_once('/').unwrap_or((rest, "")); + let zone = percent_decode(raw)?; + (!zone.is_empty() + && zone.len() <= 128 + && zone.bytes().all(|byte| (0x21..=0x7e).contains(&byte))) + .then_some((zone, tail)) +} + +fn percent_decode(segment: &str) -> Option { + let bytes = segment.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + out.push(u8::from_str_radix(segment.get(i + 1..i + 3)?, 16).ok()?); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).ok() +} + +/// Percentiles of the last values (tick timing in the debug info). +#[derive(Debug, Clone, Default)] +pub struct Window { + values: std::collections::VecDeque, +} + +impl Window { + const MAX: usize = 900; + + /// Add a value. + pub fn push(&mut self, value: f64) { + if self.values.len() == Self::MAX { + self.values.pop_front(); + } + self.values.push_back(value); + } + + /// The `p` percentile (0 when empty). + pub fn percentile(&self, p: f64) -> f64 { + if self.values.is_empty() { + return 0.0; + } + let mut sorted: Vec = self.values.iter().copied().collect(); + sorted.sort_by(f64::total_cmp); + sorted[((sorted.len() - 1) as f64 * p.clamp(0.0, 1.0)).round() as usize] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn vars(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + move |name| map.get(name).cloned() + } + + #[test] + fn options_need_a_ticket_secret_and_default_the_rest() { + assert!(ObjectOptions::from_vars(vars(&[])).is_err()); + assert!(ObjectOptions::from_vars(vars(&[(TICKET_SECRET_VAR, "short")])).is_err()); + let options = + ObjectOptions::from_vars(vars(&[(TICKET_SECRET_VAR, "0123456789abcdef")])).unwrap(); + assert_eq!((options.tick_hz, options.patch_hz), (30, 20)); + assert_eq!(options.hibernate_after, Duration::from_secs(10)); + assert!(options.nodes.is_empty(), "node lookup is off by default"); + assert_eq!(options.fact_service, "celld-zone"); + let options = ObjectOptions::from_vars(vars(&[ + (TICKET_SECRET_VAR, "0123456789abcdef"), + (TICK_HZ_VAR, "10"), + (PATCH_HZ_VAR, "20"), + (NODES_VAR, "node-a=127.0.0.1:1, bad ,node-b=127.0.0.1:2"), + ("DISTRIBUTED_INTERNAL_SECRET", "s"), + ])) + .unwrap(); + assert_eq!(options.patch_hz, 10, "patches never outpace ticks"); + assert_eq!(options.nodes.len(), 2); + assert_eq!(options.internal_secret.as_deref(), Some("s")); + assert!(ObjectOptions::from_vars(vars(&[ + (TICKET_SECRET_VAR, "0123456789abcdef"), + (TICK_HZ_VAR, "0"), + ])) + .is_err()); + } + + #[test] + fn the_tick_schedule_corrects_itself_and_skips_missed_deadlines() { + let mut schedule = TickSchedule::new(1000.0, 20); + assert_eq!(schedule.deadline(), 1050.0); + assert_eq!(schedule.wait_ms(1010.0), 40.0); + // Woke 3 ms late: the next deadline stays on the grid (no drift). + assert_eq!(schedule.woke(1053.0), 3.0); + assert_eq!(schedule.deadline(), 1100.0); + // A 130 ms stall skips the missed deadlines. + assert_eq!(schedule.woke(1230.0), 130.0); + assert_eq!(schedule.deadline(), 1250.0); + assert_eq!(schedule.wait_ms(1300.0), 0.0); + // Early wake-ups still advance by one step. + let before = schedule.deadline(); + schedule.woke(before - 5.0); + assert_eq!(schedule.deadline(), before + 50.0); + } + + #[test] + fn dormant_rooms_hibernate_after_the_delay() { + let after = Duration::from_millis(500); + assert!(!RoomState::Active.hibernate_due(10_000.0, after)); + assert!(!RoomState::Cold.hibernate_due(10_000.0, after)); + let dormant = RoomState::Dormant { since_ms: 1_000.0 }; + assert!(!dormant.hibernate_due(1_400.0, after)); + assert!(dormant.hibernate_due(1_500.0, after)); + } + + #[test] + fn stored_facts_round_trip() { + let fact = Fact::trusted( + "kill:pit:1:m1:0", + "crawler.record_kill", + json!({ "mob": 1 }), + Some("alice".into()), + FactStamp { + zone_id: "pit.0.0".into(), + tick: 7, + activation: 9, + seq: 2, + emitted_at_ms: 1_700_000_000_000, + ..FactStamp::default() + }, + ); + let stored = StoredFact::of(&fact); + let text = serde_json::to_string(&stored).unwrap(); + let back: StoredFact = serde_json::from_str(&text).unwrap(); + assert_eq!(back.into_fact().unwrap(), fact); + assert_eq!(StoredFact::storage_key("k"), format!("{OUTBOX_PREFIX}k")); + } + + #[test] + fn wait_path_answers_are_classified() { + let ok = classify_wait_path( + 200, + &json!({ "payload": { "xp": 3 }, "receipt": { "replayed": true } }), + ) + .unwrap(); + assert_eq!(ok.payload, json!({ "xp": 3 })); + assert!(ok.replayed); + let retry = |status, code: &str| { + matches!( + classify_wait_path(status, &json!({ "code": code })), + Err(FactError::Retryable(_)) + ) + }; + assert!(retry(503, "INTERNAL")); + assert!(retry(409, "COMMAND_IN_PROGRESS")); + assert!(retry(429, "")); + assert!(!retry(401, "UNAUTHORIZED")); + assert!(!retry(422, "REJECTED")); + } + + #[test] + fn bindings_and_paths() { + assert_eq!(cell_binding("zone-activity"), "ZONE_ACTIVITY"); + assert_eq!(cell_binding("crawler"), "CRAWLER"); + assert_eq!( + zone_from_path("/rooms/", "/rooms/pit.1.-2/info"), + Some(("pit.1.-2".to_string(), "info")) + ); + assert_eq!( + zone_from_path("/rooms/", "/rooms/a%20b"), + None, + "zones are printable ASCII without spaces" + ); + assert_eq!(zone_from_path("/rooms/", "/rooms/"), None); + assert_eq!(zone_from_path("/rooms/", "/other/x"), None); + } + + #[test] + fn windows_report_percentiles() { + let mut window = Window::default(); + assert_eq!(window.percentile(0.5), 0.0); + for v in 1..=100 { + window.push(f64::from(v)); + } + assert_eq!(window.percentile(0.0), 1.0); + assert_eq!(window.percentile(1.0), 100.0); + assert!((window.percentile(0.99) - 99.0).abs() <= 1.0); + } +} diff --git a/distributed_game/src/command_sink.rs b/distributed_game/src/command_sink.rs index da08e751..b5acc84b 100644 --- a/distributed_game/src/command_sink.rs +++ b/distributed_game/src/command_sink.rs @@ -1,26 +1,27 @@ -//! [`CommandFactSink`]: a zone server's facts dispatched as commands through -//! any [`CommandHost`] — the same durable aggregates whether they run in -//! celld cells ([`CelldCommandHost`], `POST {celld}/{kind}/{shard}/{command}` -//! over the internal path) or in an ordinary service over any repository -//! ([`LocalCommandHost`]: in-memory, SQLite, Postgres). +//! [`CommandFactSink`]: a zone's facts dispatched as commands through any +//! [`FactHost`] — the same durable aggregates whether they are reached over +//! celld's internal HTTP path ([`CelldCommandHost`], feature `command-host`, +//! `POST {celld}/{kind}/{shard}/{command}`), object-to-object from a zone +//! running as a celld Durable Object +//! ([`CelldObjectHost`](crate::celld_zone::CelldObjectHost), feature +//! `celld-zone`, wasm32), or in an ordinary service over any repository +//! ([`LocalCommandHost`], feature `command-host`). //! -//! One sink per zone server routes every fact by its command +//! One sink per zone (host) routes every fact by its command //! ([`FactCommand::COMMAND`](crate::FactCommand::COMMAND)) to a registered //! [`FactRoute`]: a cell kind and a shard function of the fact's input and //! trusted session (principal + [`FactStamp`](crate::FactStamp)). A host -//! that addresses cells uses both ([`FactRouting`]); a service routes by -//! command and its handler picks the stream from the same session or input. -//! Player lifecycle facts (`zone.*`, [`crate::lifecycle`]) route to the -//! presence aggregate of the player's account ([`CommandFactSink::lifecycle_route`]). +//! that addresses cells uses both; a service routes by command and its +//! handler picks the stream from the same session or input. Player +//! lifecycle facts (`zone.*`, [`crate::lifecycle`]) route to the presence +//! aggregate of the player's account ([`CommandFactSink::lifecycle_route`]). //! //! - **Exactly one commit per key.** The command id is //! [`command_id_for`]`(service_id, fact key)`, so a retry after a timeout //! replays through the aggregate's command ledger (`replayed` receipt). //! - **Trusted identity.** The principal (bound by the zone host from the -//! verified ticket) is the session's `x-user-id` and the verified -//! principal ([`VerifiedPrincipal::from_trusted_transport`]); the stamp -//! travels as `x-game-*` session variables (forwarded to cells as trusted -//! metadata, [`CelldCommandHost::forward_session_prefix`]). +//! verified ticket) is the session's `x-user-id`; the stamp travels as +//! `x-game-*` session variables (forwarded to cells as trusted metadata). //! - **No local durable fallback.** A host that is slow or unreachable, a //! 5xx, or a command still in progress is [`FactError::Retryable`]: the fact //! stays in the zone's queue (use @@ -28,17 +29,24 @@ //! unrouted or rejected command is [`FactError::Permanent`]. use std::sync::Arc; +#[cfg(feature = "command-host")] use std::time::Duration; +#[cfg(feature = "command-host")] use distributed::cell_host::{CelldCommandHost, InternalHttpSecret}; +#[cfg(feature = "command-host")] use distributed::command_dispatch::{CommandHost, LocalCommandHost}; +#[cfg(feature = "command-host")] use distributed::graphql::identity::VerifiedPrincipal; -use distributed::microsvc::{CausalDispatchError, Session}; +#[cfg(feature = "command-host")] +use distributed::microsvc::CausalDispatchError; +use distributed::microsvc::Session; use serde_json::Value; use crate::cell::{command_id_for, session_stamp, trusted_session}; use crate::facts::{BoxFuture, Fact, FactCommand, FactError, FactReceipt, FactSink}; use crate::lifecycle::LIFECYCLE_COMMANDS; +#[cfg(feature = "command-host")] use crate::stamp::SESSION_PREFIX; type ShardFn = Arc Option + Send + Sync>; @@ -109,26 +117,41 @@ impl FactRoute { } } -/// A [`CommandHost`] that learns the fact routes it needs. -pub trait FactRouting { - /// Register `route` (a host that addresses aggregates by kind and shard - /// records it; one that routes by command ignores it). - fn register_fact_route(&mut self, route: &FactRoute); +/// One routed fact, ready for a [`FactHost`]. +#[derive(Debug, Clone)] +pub struct FactCall<'a> { + /// Durable command name. + pub command: &'a str, + /// Aggregate kind of the route. + pub kind: &'a str, + /// Validated shard (non-empty, printable ASCII, ≤ 256 bytes). + pub shard: String, + /// Deterministic command id ([`command_id_for`]). + pub command_id: String, + /// Command input. + pub input: &'a Value, + /// Verified principal of the fact (or the sink's system principal). + pub principal: String, + /// Trusted session: `x-user-id` + the `x-game-*` stamp. + pub session: Session, } -/// A service routes by command: its handler selects the stream. -impl FactRouting for LocalCommandHost { +/// What a [`CommandFactSink`] dispatches through: anything that runs one +/// durable command idempotently by command id. Implemented for +/// [`CelldCommandHost`] and [`LocalCommandHost`] (feature `command-host`) +/// and for the celld object host (feature `celld-zone`). +pub trait FactHost: Send + Sync + 'static { + /// Learn `route` (a host that addresses aggregates by kind and shard + /// records it; one that routes by command ignores it). fn register_fact_route(&mut self, _route: &FactRoute) {} -} -/// celld addresses cells: `{kind}/{shard}/{command}`. -impl FactRouting for CelldCommandHost { - fn register_fact_route(&mut self, route: &FactRoute) { - let shard = route.shard.clone(); - self.add_cell_route(route.command(), route.kind(), move |input, session| { - shard(input, session) - }); - } + /// Run `call` once per command id. Classify failures: unreachable, + /// 5xx, in progress → [`FactError::Retryable`]; anything else → + /// [`FactError::Permanent`]. + fn dispatch_fact<'a>( + &'a self, + call: FactCall<'a>, + ) -> BoxFuture<'a, Result>; } /// See the module docs. @@ -150,7 +173,7 @@ impl Clone for CommandFactSink { } } -impl CommandFactSink { +impl CommandFactSink { /// A sink dispatching through `host` under command-ledger `service_id`; /// zone facts without a player use `system_principal`. pub fn new( @@ -202,6 +225,11 @@ impl CommandFactSink { &self.host } + /// The command-ledger service id. + pub fn service_id(&self) -> &str { + &self.service_id + } + /// The route a fact would take, if any. pub fn route_for(&self, command: &str) -> Option<&FactRoute> { self.routes.iter().find(|route| route.command == command) @@ -209,6 +237,15 @@ impl CommandFactSink { /// `(principal, trusted session, command id)` of a routed fact. pub fn prepare(&self, fact: &Fact) -> Result<(String, Session, String), FactError> { + let (_, _, principal, session, command_id) = self.resolve(fact)?; + Ok((principal, session, command_id)) + } + + #[allow(clippy::type_complexity)] + fn resolve( + &self, + fact: &Fact, + ) -> Result<(&FactRoute, String, String, Session, String), FactError> { let route = self.route_for(fact.command()).ok_or_else(|| { FactError::Permanent(format!("no route for fact command `{}`", fact.command())) })?; @@ -217,7 +254,7 @@ impl CommandFactSink { .unwrap_or(&self.system_principal) .to_string(); let session = trusted_session(&principal, fact); - route + let shard = route .shard(fact.input(), &session) .filter(|shard| { !shard.is_empty() @@ -232,6 +269,8 @@ impl CommandFactSink { )) })?; Ok(( + route, + shard, principal, session, command_id_for(&self.service_id, fact.key()), @@ -239,7 +278,27 @@ impl CommandFactSink { } } +impl FactSink for CommandFactSink { + fn commit<'a>(&'a self, fact: &'a Fact) -> BoxFuture<'a, Result> { + Box::pin(async move { + let (route, shard, principal, session, command_id) = self.resolve(fact)?; + self.host + .dispatch_fact(FactCall { + command: fact.command(), + kind: route.kind(), + shard, + command_id, + input: fact.input(), + principal, + session, + }) + .await + }) + } +} + /// celld slow, down or still running the command: keep the fact queued. +#[cfg(feature = "command-host")] fn classify(error: CausalDispatchError) -> FactError { let detail = format!("{}: {error:?}", error.code()); match &error { @@ -258,36 +317,69 @@ fn classify(error: CausalDispatchError) -> FactError { } } -impl FactSink for CommandFactSink { - fn commit<'a>(&'a self, fact: &'a Fact) -> BoxFuture<'a, Result> { - Box::pin(async move { - let (principal, session, command_id) = self.prepare(fact)?; - let result = self - .host - .invoke( - fact.command(), - &command_id, - fact.input().clone(), - session, - VerifiedPrincipal::from_trusted_transport(&principal), - None, - ) - .await - .map_err(classify)?; - Ok(FactReceipt { - payload: result.payload().clone(), - replayed: result.replayed(), - }) +/// Any [`CommandHost`]: `invoke` with the fact's trusted session. +#[cfg(feature = "command-host")] +fn invoke_command_host<'a, H: CommandHost>( + host: &'a H, + call: FactCall<'a>, +) -> BoxFuture<'a, Result> { + Box::pin(async move { + let result = host + .invoke( + call.command, + &call.command_id, + call.input.clone(), + call.session, + VerifiedPrincipal::from_trusted_transport(&call.principal), + None, + ) + .await + .map_err(classify)?; + Ok(FactReceipt { + payload: result.payload().clone(), + replayed: result.replayed(), }) + }) +} + +/// A service routes by command: its handler selects the stream. +#[cfg(feature = "command-host")] +impl FactHost for LocalCommandHost { + fn dispatch_fact<'a>( + &'a self, + call: FactCall<'a>, + ) -> BoxFuture<'a, Result> { + invoke_command_host(self, call) + } +} + +/// celld addresses cells: `{kind}/{shard}/{command}`. +#[cfg(feature = "command-host")] +impl FactHost for CelldCommandHost { + fn register_fact_route(&mut self, route: &FactRoute) { + let shard = route.shard.clone(); + self.add_cell_route(route.command(), route.kind(), move |input, session| { + shard(input, session) + }); + } + + fn dispatch_fact<'a>( + &'a self, + call: FactCall<'a>, + ) -> BoxFuture<'a, Result> { + invoke_command_host(self, call) } } /// Default per-request timeout of [`CelldFactSink`]. +#[cfg(feature = "command-host")] pub const DEFAULT_CELLD_FACT_TIMEOUT: Duration = Duration::from_secs(10); /// Facts into celld cells over the internal path. +#[cfg(feature = "command-host")] pub type CelldFactSink = CommandFactSink; +#[cfg(feature = "command-host")] impl CommandFactSink { /// A sink for the cells at `celld_url` under command-ledger /// `service_id`: a remote-only [`CelldCommandHost`] forwarding the fact @@ -324,7 +416,7 @@ impl CommandFactSink { } } -#[cfg(test)] +#[cfg(all(test, feature = "command-host"))] mod tests { use super::*; use crate::stamp::FactStamp; diff --git a/distributed_game/src/facts.rs b/distributed_game/src/facts.rs index baf00eb5..66e78466 100644 --- a/distributed_game/src/facts.rs +++ b/distributed_game/src/facts.rs @@ -320,6 +320,7 @@ pub struct FactQueue { in_flight: HashSet, capacity: usize, script: Option, + journal: Option>, } /// Worker-side handle consumed by [`FactFeed::run`]. @@ -342,6 +343,7 @@ pub fn fact_channel(capacity: usize, policy: RetryPolicy) -> (FactQueue, FactFee in_flight: HashSet::new(), capacity: capacity.max(1), script: None, + journal: None, }, FactFeed { shared, @@ -362,6 +364,9 @@ impl FactQueue { return Err(EmitError::Backpressure); } self.in_flight.insert(fact.key.clone()); + if let Some(journal) = self.journal.as_mut() { + journal.push(fact.clone()); + } if self.script.is_some() { return Ok(()); } @@ -374,6 +379,22 @@ impl FactQueue { Ok(()) } + /// Keep a copy of every accepted fact until [`take_journal`](Self::take_journal): + /// a host that keeps its own durable outbox writes them there right + /// after the tick that emitted them (the celld zone host does). + pub fn with_journal(mut self) -> Self { + self.journal = Some(Vec::new()); + self + } + + /// Facts accepted since the last call (empty without a journal). + pub fn take_journal(&mut self) -> Vec { + self.journal + .as_mut() + .map(std::mem::take) + .unwrap_or_default() + } + /// Settlements that arrived since the last call. pub fn drain(&mut self) -> Vec { let settled: Vec<_> = self.settlements.try_iter().collect(); diff --git a/distributed_game/src/host.rs b/distributed_game/src/host.rs index 45b9106d..cda03f58 100644 --- a/distributed_game/src/host.rs +++ b/distributed_game/src/host.rs @@ -1014,6 +1014,16 @@ impl ZoneHost { self.core.in_flight() } + /// Facts emitted since the last call, when the fact queue keeps a + /// journal ([`FactQueue::with_journal`]); empty otherwise. + pub fn take_emitted_facts(&mut self) -> Vec { + self.core + .facts + .as_mut() + .map(FactQueue::take_journal) + .unwrap_or_default() + } + /// True after [`dispose`](Self::dispose). pub fn is_disposed(&self) -> bool { self.disposed diff --git a/distributed_game/src/lib.rs b/distributed_game/src/lib.rs index 828b3a40..03fe2fdb 100644 --- a/distributed_game/src/lib.rs +++ b/distributed_game/src/lib.rs @@ -13,6 +13,9 @@ //! priority accumulation. Wire format: [`protocol`] / `PROTOCOL.md`. //! - Transport (`net` feature): axum WebSocket server hosting many zones //! with HMAC-signed ticket admission ([`net`]). +//! - celld (`celld-zone` feature, wasm32): each zone is a celld Durable +//! Object; celld places, forwards, hibernates and recovers it +//! ([`celld_zone`]). //! //! - Authoring derives (re-exported from `distributed_game_macros`): //! `#[derive(Replicated)]` views generate a kind's schema and field @@ -56,10 +59,12 @@ pub mod bots; pub mod cell; #[cfg(feature = "celld")] pub mod celld; +#[cfg(feature = "celld-zone")] +pub mod celld_zone; pub mod claims; pub mod clock; pub mod codec; -#[cfg(feature = "command-host")] +#[cfg(feature = "cell")] pub mod command_sink; pub mod entity; pub mod facts; diff --git a/distributed_game/src/world/mod.rs b/distributed_game/src/world/mod.rs index 2183010e..b07d536f 100644 --- a/distributed_game/src/world/mod.rs +++ b/distributed_game/src/world/mod.rs @@ -14,10 +14,12 @@ //! - [`allocation`]: [`ZoneAllocation`] records written by the service that //! allocates a zone, read before the zone is created //! ([`ZoneActivation`], [`ZoneAllocationStore`]). -//! - `zone_status` (feature `cell`): the read model of every zone's lease -//! and hibernation slot, projected from the `zone_lease` / `zone_state` -//! cells' outward events (active / hibernated / cold without opening a -//! cell). +//! - `zone_activity` (feature `cell`): a celld-hosted zone's own lifecycle +//! reports (active / dormant / hibernated) as a cell. +//! - `zone_status` (feature `cell`): the read model of every zone's lease, +//! hibernation slot and activity, projected from the `zone_lease` / +//! `zone_state` / `zone_activity` cells' outward events (active / dormant +//! / hibernated / cold without opening a cell). //! - [`barrier`]: content-version policy types shared by the host and the //! world barrier ([`StalePolicy`], [`BarrierMode`]). @@ -28,6 +30,9 @@ pub mod ownership; pub mod state; pub mod topology; #[cfg(feature = "cell")] +#[allow(missing_docs)] +pub mod zone_activity; +#[cfg(feature = "cell")] // The `sourced` and `Snapshot` derives generate undocumented public items. #[allow(missing_docs)] pub mod zone_lease; diff --git a/distributed_game/src/world/mutations/save_zone_activity.mutation.graphql b/distributed_game/src/world/mutations/save_zone_activity.mutation.graphql new file mode 100644 index 00000000..368e212f --- /dev/null +++ b/distributed_game/src/world/mutations/save_zone_activity.mutation.graphql @@ -0,0 +1,5 @@ +# Syntax-only read-model mutation → MutationProgram IR (not a public field). +# The row is the zone's post-transition `ZoneActivityStatus`. +mutation SaveZoneActivity { + upsert_zone_activities(object: $input.activity) +} diff --git a/distributed_game/src/world/zone_activity.rs b/distributed_game/src/world/zone_activity.rs new file mode 100644 index 00000000..d9fbc31a --- /dev/null +++ b/distributed_game/src/world/zone_activity.rs @@ -0,0 +1,288 @@ +//! What a zone running as a celld object is doing, as a cell: +//! `ZoneActivity:{zone_id}` (cell kind [`ZONE_ACTIVITY_KIND`]). +//! +//! A zone hosted by [`celld_zone`](crate::celld_zone) reports its own +//! lifecycle — woke up (`active`, `restored` when it came back from a +//! snapshot), emptied (`dormant`), slept (`hibernated`) — as facts +//! ([`RecordActivity`], command [`RECORD`]) through its fact sink, so the +//! transitions are durable and exactly once like any other zone fact. +//! +//! | Command | Effect | Outward event | +//! |---|---|---| +//! | `zone_activity.record` | the reported status, if emitted after the one stored | `zone.activity_changed` | +//! +//! Facts can settle out of order (retries, concurrent commits): a report +//! emitted before the stored one ([`FactStamp::emission_order`]) is +//! ignored. The outward event carries the post-transition +//! [`ZoneActivityStatus`]; the [`zone_status`](super::zone_status) read model +//! projects it into `zone_activity` rows (minimaps, dashboards). +//! +//! Only the zone itself may report: the fact's principal must be the +//! zone's system principal [`zone_principal`]`(zone)`, and the stamp's zone +//! is the shard. +//! +//! `node` and `isolate` are debug details (which celld node and V8 isolate +//! ran the activation, when known); nothing may route by them. + +use distributed::cell_host::AggregateCell; +use distributed::command::{typed_command, PreparedCommand, Succeeded}; +use distributed::microsvc::{ + CausalCommandContext, CausalRouteDependencies, HandlerError, PortableCommand, Routes, +}; +use distributed::{sourced, Entity}; +use serde::{Deserialize, Serialize}; + +use crate::cell::session_stamp; +use crate::FactStamp; + +/// Aggregate type (cell `ZoneActivity:{zone_id}`). +pub const ZONE_ACTIVITY: &str = "ZoneActivity"; +/// Cell kind (URL segment / Durable Object routing) of zone-activity cells. +pub const ZONE_ACTIVITY_KIND: &str = "zone-activity"; +/// The one command (a zone fact). +pub const RECORD: &str = "zone_activity.record"; + +/// Status: ticking with players (or just woke up). +pub const ACTIVITY_ACTIVE: &str = "active"; +/// Status: empty, still in memory, snapshot written. +pub const ACTIVITY_DORMANT: &str = "dormant"; +/// Status: asleep; the next join restores the snapshot. +pub const ACTIVITY_HIBERNATED: &str = "hibernated"; + +/// The system principal a zone reports its own activity under. +pub fn zone_principal(zone_id: &str) -> String { + format!("zone:{zone_id}") +} + +/// One zone's activity. +#[derive(Clone, Debug, Default, Serialize, Deserialize, distributed::Snapshot)] +pub struct ZoneActivity { + entity: Entity, + status: String, + activation: String, + restored: bool, + players: u32, + node: String, + isolate: String, + activations: u64, + changed_at_ms: u64, + /// Ordering key of the report that set the current status. + last: Option, +} + +/// The emission-order key of the last applied report. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct StampKey { + /// Zone. + pub zone_id: String, + /// Activation epoch. + pub activation: u64, + /// Tick. + pub tick: u64, + /// Emission wall time. + pub emitted_at_ms: u64, + /// Emission sequence. + pub seq: u64, +} + +impl StampKey { + fn of(stamp: &FactStamp) -> Self { + Self { + zone_id: stamp.zone_id.clone(), + activation: stamp.activation, + tick: stamp.tick, + emitted_at_ms: stamp.emitted_at_ms, + seq: stamp.seq, + } + } + + fn stamp(&self) -> FactStamp { + FactStamp { + zone_id: self.zone_id.clone(), + activation: self.activation, + tick: self.tick, + emitted_at_ms: self.emitted_at_ms, + seq: self.seq, + ..FactStamp::default() + } + } +} + +/// The public state carried by `zone.activity_changed`: one +/// `zone_activity` read-model row. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, distributed::DomainState)] +#[domain_state(version = 1)] +pub struct ZoneActivityStatus { + /// Zone (the cell's stream id). + pub zone_id: String, + /// [`ACTIVITY_ACTIVE`], [`ACTIVITY_DORMANT`] or [`ACTIVITY_HIBERNATED`]. + pub status: String, + /// Id of the object activation that reported it. + pub activation: String, + /// That activation restored a snapshot. + pub restored: bool, + /// Players in the zone when reported. + pub players: u32, + /// celld node running the activation (debug; empty when unknown). + pub node: String, + /// V8 isolate running the activation (debug). + pub isolate: String, + /// Activations seen so far. + pub activations: u64, + /// When the zone reported it (zone clock, Unix ms). + pub changed_at_ms: u64, +} + +impl From<&ZoneActivity> for ZoneActivityStatus { + fn from(activity: &ZoneActivity) -> Self { + Self { + zone_id: activity.entity.id().to_string(), + status: activity.status.clone(), + activation: activity.activation.clone(), + restored: activity.restored, + players: activity.players, + node: activity.node.clone(), + isolate: activity.isolate.clone(), + activations: activity.activations, + changed_at_ms: activity.changed_at_ms, + } + } +} + +#[sourced( + entity, + aggregate_type = "ZoneActivity", + events = "ZoneActivityEvent", + domain_state = ZoneActivityStatus +)] +impl ZoneActivity { + #[event("zone.activity_changed", version = 1, domain)] + fn changed(&mut self, zone_id: String, report: RecordActivity, stamp: StampKey) { + if self.entity.id().is_empty() { + self.entity.set_id(zone_id); + } + if report.activation != self.activation { + self.activations += 1; + } + self.status = report.status; + self.activation = report.activation; + self.restored = report.restored; + self.players = report.players; + self.node = report.node; + self.isolate = report.isolate; + self.changed_at_ms = stamp.emitted_at_ms; + self.last = Some(stamp); + } +} + +impl ZoneActivity { + /// Current status (empty before the first report). + pub fn status(&self) -> &str { + &self.status + } + + /// The public row. + pub fn row(&self) -> ZoneActivityStatus { + self.into() + } +} + +/// `zone_activity.record` input: what the zone reports about itself. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Default, + Serialize, + Deserialize, + distributed::CommandInput, + crate::FactCommand, +)] +#[fact(command = "zone_activity.record")] +pub struct RecordActivity { + /// [`ACTIVITY_ACTIVE`], [`ACTIVITY_DORMANT`] or [`ACTIVITY_HIBERNATED`]. + pub status: String, + /// Object activation id. + pub activation: String, + /// The activation restored a snapshot. + pub restored: bool, + /// Players present. + pub players: u32, + /// celld node (debug; empty when unknown). + pub node: String, + /// V8 isolate (debug). + pub isolate: String, +} + +/// The applied (or ignored) report. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, distributed::CommandOutput)] +pub struct ActivityApplied { + /// False when a later-emitted report already set the status. + pub applied: bool, + /// The status after the command. + pub status: String, +} + +fn rejected(message: impl std::fmt::Display) -> HandlerError { + HandlerError::Rejected(message.to_string()) +} + +async fn handle_record( + ctx: &CausalCommandContext<'_, ZoneActivity>, + input: RecordActivity, +) -> Result>, HandlerError> { + if ![ACTIVITY_ACTIVE, ACTIVITY_DORMANT, ACTIVITY_HIBERNATED].contains(&input.status.as_str()) { + return Err(rejected(format!("unknown activity `{}`", input.status))); + } + let stamp = session_stamp(ctx.session()) + .ok_or_else(|| rejected("activity reports are zone facts and need a fact stamp"))?; + if ctx.user_id()? != zone_principal(&stamp.zone_id) { + return Err(rejected("only the zone itself reports its activity")); + } + let repo = ctx.repo(); + let mut activity = match repo.get(&stamp.zone_id).await? { + Some(activity) => activity, + None => repo.create(), + }; + let newer = activity + .last + .as_ref() + .is_none_or(|last| last.stamp().emission_order(&stamp) == std::cmp::Ordering::Less); + if !newer { + let status = activity.status.clone(); + return repo.commit(activity)?.succeeded(ActivityApplied { + applied: false, + status, + }); + } + let status = input.status.clone(); + activity + .changed(stamp.zone_id.clone(), input, StampKey::of(&stamp)) + .map_err(rejected)?; + repo.publish_events() + .commit(activity)? + .succeeded(ActivityApplied { + applied: true, + status, + }) +} + +struct Record; +impl PortableCommand for Record +where + D: CausalRouteDependencies + Send + Sync + 'static, +{ + fn install(self, routes: Routes) -> Routes { + routes + .typed_command(typed_command::>( + RECORD, + )) + .handle(handle_record) + } +} + +/// Mount the zone-activity command on a `ZoneActivity` cell. +pub fn mount_zone_activity(cell: AggregateCell) -> AggregateCell { + cell.mount(Record) +} diff --git a/distributed_game/src/world/zone_status.rs b/distributed_game/src/world/zone_status.rs index 2d029ec2..5ac3d4f1 100644 --- a/distributed_game/src/world/zone_status.rs +++ b/distributed_game/src/world/zone_status.rs @@ -8,11 +8,15 @@ //! |---|---|---| //! | [`ZoneLease`](super::zone_lease::ZoneLease) | `zone.activated`, `zone.released`, `zone.lease_expired` | [`ZoneLeases`] (`zone_leases`) | //! | [`ZoneState`](super::zone_state::ZoneState) | `zone_state.saved`, `zone_state.deleted` | [`ZoneSnapshots`] (`zone_snapshots`) | +//! | [`ZoneActivity`](super::zone_activity::ZoneActivity) (zones hosted as celld objects) | `zone.activity_changed` | [`ZoneActivities`] (`zone_activities`) | //! //! Each event carries its cell's post-transition state, so a row is an -//! upsert of the body ([`ZONE_LEASES`], [`ZONE_SNAPSHOTS`]); the projector -//! runtime orders and deduplicates per stream. [`ZoneStatus::of`] combines -//! a zone's two rows. Mount [`projectors`] on a consumer service (any read +//! upsert of the body ([`ZONE_LEASES`], [`ZONE_SNAPSHOTS`], +//! [`ZONE_ACTIVITIES`]); the projector runtime orders and deduplicates per +//! stream. [`ZoneStatus::of`] combines a zone's rows: a zone hosted as a +//! celld object ([`crate::celld_zone`]) has only an activity row (active / +//! dormant / hibernated); a zone on the native multi-host server has lease +//! and snapshot rows. Mount [`projectors`] on a consumer service (any read //! model store: SQLite, Postgres) fed by the bus the cells' outbox reaches. //! //! The read model is eventually consistent: it is for views (world maps, @@ -27,6 +31,9 @@ use distributed::{ }; use serde::{Deserialize, Serialize}; +use super::zone_activity::{ + ZoneActivityActivityChangedDomainEvent, ACTIVITY_ACTIVE, ACTIVITY_DORMANT, ACTIVITY_HIBERNATED, +}; use super::zone_lease::{ ZoneLeaseActivatedDomainEvent, ZoneLeaseLeaseExpiredDomainEvent, ZoneLeaseReleasedDomainEvent, STATUS_ACTIVE, @@ -44,6 +51,10 @@ pub const SNAPSHOTS_PROJECTOR: &str = "project_zone_snapshots"; pub const LEASES_EPOCH: &str = "zone-leases-v1"; /// Epoch of [`ZONE_SNAPSHOTS`]. pub const SNAPSHOTS_EPOCH: &str = "zone-snapshots-v1"; +/// Projector of [`ZoneActivities`]. +pub const ACTIVITIES_PROJECTOR: &str = "project_zone_activities"; +/// Epoch of [`ZONE_ACTIVITIES`]. +pub const ACTIVITIES_EPOCH: &str = "zone-activities-v1"; /// One `zone_leases` row: the last lease transition of a zone /// ([`ZoneLeaseStatus`](super::zone_lease::ZoneLeaseStatus); the type is @@ -80,6 +91,39 @@ pub struct ZoneSnapshots { pub bytes: i64, } +/// One `zone_activities` row: what a zone hosted as a celld object last +/// reported about itself +/// ([`ZoneActivityStatus`](super::zone_activity::ZoneActivityStatus)). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ReadModel)] +#[table("zone_activities")] +pub struct ZoneActivities { + /// Zone id. + #[id("zone_id")] + pub zone_id: String, + /// `active`, `dormant` or `hibernated`. + pub status: String, + /// Object activation id. + pub activation: String, + /// The activation restored a snapshot. + pub restored: bool, + /// Players when reported. + pub players: i64, + /// celld node of the activation (debug; empty when unknown). + pub node: String, + /// V8 isolate of the activation (debug). + pub isolate: String, + /// Activations seen. + pub activations: i64, + /// When the zone reported it (Unix ms). + pub changed_at_ms: i64, +} + +/// Upsert a [`ZoneActivities`] row from a `zone.activity_changed` body. +#[allow(non_snake_case)] +pub fn SaveZoneActivity() -> Mutation<()> { + mutation_file!("src/world/mutations/save_zone_activity.mutation.graphql") +} + /// Upsert a [`ZoneLeases`] from a lease event's state body. #[allow(non_snake_case)] pub fn SaveZoneLease() -> Mutation<()> { @@ -124,11 +168,52 @@ distributed::projection! { }; } -/// Physical read-model catalog (`zone_leases`, `zone_snapshots`). +distributed::projection! { + pub const ZONE_ACTIVITIES: ProjectionDescriptor = { + name: "project_zone_activities", + version: 1, + epoch: "zone-activities-v1", + model: ZoneActivities, + on { + events: [ZoneActivityActivityChangedDomainEvent], + mutation: SaveZoneActivity, + input: { activity: body }, + }, + }; +} + +/// Physical read-model catalog (`zone_leases`, `zone_snapshots`, +/// `zone_activities`). pub fn read_model_catalog() -> ReadModelCatalog { ReadModelCatalog::new(APPLICATION) .table_schema(ZoneLeases::schema().clone()) .table_schema(ZoneSnapshots::schema().clone()) + .table_schema(ZoneActivities::schema().clone()) +} + +/// The modeled projector of [`ZoneActivities`] alone (a consumer that +/// shows celld-hosted zones, e.g. a minimap service), applying +/// [`ZONE_ACTIVITIES`]. Mount it on a consumer service named +/// [`APPLICATION`] (or any name of the caller's, with its own cursor). +pub fn activity_projector() -> SurfaceProjector { + activity_projector_for(APPLICATION) +} + +/// [`activity_projector`] owned by `application` (when the consumer service +/// is not named [`APPLICATION`]). +pub fn activity_projector_for(application: &str) -> SurfaceProjector { + LocalProjectionMountsBuilder::new(application, "ordered-domain-events") + .expect("projection source") + .eventual_model::( + ACTIVITIES_PROJECTOR, + ZONE_ACTIVITIES, + ACTIVITIES_EPOCH, + ) + .expect("zone activities mount") + .build() + .expect("projection catalog") + .projector(ACTIVITIES_PROJECTOR) + .expect("zone activities projector") } /// The modeled projector mounts `(leases, snapshots)`: mount both on one @@ -157,11 +242,16 @@ pub fn projectors() -> (SurfaceProjector, SurfaceProjector) { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "state", rename_all = "camelCase")] pub enum ZoneStatus { - /// A holder has the lease. + /// Running: a holder has the lease (native multi-host server; `holder` + /// is the host) or a celld object reported `active` (`holder` is its + /// node when known, a debug detail). Active { - /// The lease holder. + /// The lease holder, or the celld node (may be empty). holder: String, }, + /// A celld-hosted zone that emptied: still in memory, snapshot written, + /// about to hibernate. + Dormant, /// No holder; a hibernation snapshot is stored. Hibernated { /// Snapshot size in bytes. @@ -172,6 +262,18 @@ pub enum ZoneStatus { } impl ZoneStatus { + /// The status of a celld-hosted zone from its activity row. + pub fn of_activity(activity: Option<&ZoneActivities>) -> Self { + match activity.map(|row| row.status.as_str()) { + Some(ACTIVITY_ACTIVE) => Self::Active { + holder: activity.map(|row| row.node.clone()).unwrap_or_default(), + }, + Some(ACTIVITY_DORMANT) => Self::Dormant, + Some(ACTIVITY_HIBERNATED) => Self::Hibernated { bytes: 0 }, + _ => Self::Cold, + } + } + /// Combine a zone's rows (either may be missing). pub fn of(lease: Option<&ZoneLeases>, snapshot: Option<&ZoneSnapshots>) -> Self { if let Some(lease) = lease.filter(|lease| lease.status == STATUS_ACTIVE) { @@ -224,7 +326,28 @@ mod tests { assert_eq!(ZoneStatus::of(None, None), ZoneStatus::Cold); // The projections compile against the cells' outward events. let (leases, snapshots) = projectors(); - drop((leases, snapshots)); - assert_eq!(read_model_catalog().tables.len(), 2); + drop((leases, snapshots, activity_projector())); + assert_eq!(read_model_catalog().tables.len(), 3); + let activity = |status: &str| ZoneActivities { + zone_id: "z".into(), + status: status.into(), + node: "node-a".into(), + ..ZoneActivities::default() + }; + assert_eq!( + ZoneStatus::of_activity(Some(&activity("active"))), + ZoneStatus::Active { + holder: "node-a".into() + } + ); + assert_eq!( + ZoneStatus::of_activity(Some(&activity("dormant"))), + ZoneStatus::Dormant + ); + assert_eq!( + ZoneStatus::of_activity(Some(&activity("hibernated"))), + ZoneStatus::Hibernated { bytes: 0 } + ); + assert_eq!(ZoneStatus::of_activity(None), ZoneStatus::Cold); } } diff --git a/distributed_game/tests/celld_zone/main.rs b/distributed_game/tests/celld_zone/main.rs new file mode 100644 index 00000000..37b9f501 --- /dev/null +++ b/distributed_game/tests/celld_zone/main.rs @@ -0,0 +1,420 @@ +//! Zones as celld Durable Objects (`celld_zone`), end to end against +//! `tests/celld/zone-worker` (a tally zone, its Tally cells and its +//! ZoneActivity cell): +//! +//! - `single_node`: `celld dev`. Join with a ticket, input, facts settle in +//! 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. +//! - `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 +//! closes with 1012, the client rejoins through another node, the zone +//! restores, and every fact reaches the cell exactly once. +//! +//! ```text +//! (cd tests/celld/zone-worker && worker-build --release) +//! CELLD_BIN=… CELLD_ESBUILD=… cargo test -p distributed_game \ +//! --features celld,net --test celld_zone +//! ``` +//! +//! Skips without a built worker / celld (and, for `fleet`, docker and the +//! aws CLI) unless `CELLD_ZONE_REQUIRED=1`. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use distributed_game::protocol::{error, ClientMessage, Hello, ServerMessage, PROTOCOL_VERSION}; +use distributed_game::ticket::TicketKey; +use distributed_game::{Role, TicketClaims}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::Value; +use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; +use tokio_tungstenite::tungstenite::Message; + +#[path = "../support/celld.rs"] +mod celld_support; +#[path = "../support/fleet.rs"] +mod fleet; + +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"; + +fn worker() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/celld/zone-worker") +} + +fn 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}-{}", + 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::Player).with_name(principal)) +} + +type Ws = + tokio_tungstenite::WebSocketStream>; + +#[derive(Debug)] +enum Got { + Server(ServerMessage), + Closed(Option), +} + +struct Player { + ws: Ws, + seq: u32, +} + +impl Player { + async fn connect(base: &str, zone: &str) -> Self { + Self::try_connect(base, zone).await.expect("connect") + } + + async fn try_connect(base: &str, zone: &str) -> Result { + let url = format!("{}/rooms/{zone}", base.replacen("http://", "ws://", 1)); + let (ws, _) = tokio_tungstenite::connect_async(url) + .await + .map_err(|e| e.to_string())?; + Ok(Self { ws, seq: 0 }) + } + + async fn send(&mut self, message: ClientMessage) { + self.ws + .send(Message::Binary(message.encode().into())) + .await + .expect("send"); + } + + async fn hello(&mut self, ticket: &str) { + self.send(ClientMessage::Hello(Hello { + protocol_version: PROTOCOL_VERSION, + content_version: String::new(), + capabilities: 0, + ticket: ticket.into(), + reconnect_token: String::new(), + })) + .await; + } + + async fn input(&mut self, payload: &[u8]) { + self.seq += 1; + self.send(ClientMessage::Input { + seq: self.seq, + client_tick: 0, + payload: payload.to_vec(), + }) + .await; + } + + async fn next(&mut self, within: Duration) -> Option { + let deadline = Instant::now() + within; + loop { + let left = deadline.saturating_duration_since(Instant::now()); + let frame = tokio::time::timeout(left, self.ws.next()).await.ok()?; + match frame { + Some(Ok(Message::Binary(bytes))) => match ServerMessage::decode(&bytes, &()) { + Ok(message) => return Some(Got::Server(message)), + // Deltas need the Welcome's schema; these tests skip them. + Err(_) => continue, + }, + Some(Ok(Message::Close(frame))) => { + return Some(Got::Closed(frame.map(|f| u16::from(f.code)))) + } + Some(Ok(_)) => continue, + Some(Err(_)) | None => return Some(Got::Closed(None)), + } + } + } + + /// The first message matching `pick`, skipping others (Deltas, Pongs). + async fn expect( + &mut self, + within: Duration, + what: &str, + mut pick: impl FnMut(&Got) -> Option, + ) -> T { + let deadline = Instant::now() + within; + while let Some(got) = self + .next(deadline.saturating_duration_since(Instant::now())) + .await + { + if let Some(value) = pick(&got) { + return value; + } + if let Got::Closed(code) = got { + panic!("closed ({code:?}) while waiting for {what}"); + } + } + panic!("timed out waiting for {what}"); + } + + async fn welcome(&mut self) { + self.expect(Duration::from_secs(20), "Welcome", |got| match got { + Got::Server(ServerMessage::Welcome(_)) => Some(()), + Got::Server(ServerMessage::Error { code, message }) => { + panic!("join refused: {code} {message}") + } + _ => None, + }) + .await + } + + /// Event `type_id` carrying a u64 total. + async fn total(&mut self, type_id: u32) -> u64 { + self.expect(Duration::from_secs(20), "a total event", |got| match got { + Got::Server(ServerMessage::Event { + type_id: t, + payload, + .. + }) if *t == type_id => Some(u64::from_le_bytes(payload[..8].try_into().unwrap())), + _ => None, + }) + .await + } + + async fn refused(&mut self) -> u32 { + self.expect(Duration::from_secs(20), "an Error", |got| match got { + Got::Server(ServerMessage::Error { code, .. }) => Some(*code), + Got::Server(ServerMessage::Welcome(_)) => panic!("admitted a used ticket"), + _ => None, + }) + .await + } +} + +/// GET a JSON route with the internal secret (Null on any failure: the +/// callers poll). +async fn get(base: &str, path: &str) -> Value { + let response = reqwest::Client::new() + .get(format!("{base}{path}")) + .header("x-distributed-internal-secret", INTERNAL_SECRET) + .send() + .await + .and_then(|r| r.error_for_status()); + match response { + Ok(response) => response.json().await.unwrap_or(Value::Null), + Err(_) => Value::Null, + } +} + +/// Poll `path` until `ok` (or panic after `within`). +async fn until(base: &str, path: &str, within: Duration, ok: impl Fn(&Value) -> bool) -> Value { + let deadline = Instant::now() + within; + loop { + let value = get(base, path).await; + if ok(&value) { + return value; + } + assert!(Instant::now() < deadline, "{path}: last {value}"); + tokio::time::sleep(Duration::from_millis(200)).await; + } +} + +fn status(value: &Value) -> &str { + value["activity"]["status"].as_str().unwrap_or("") +} + +fn exactly_once(value: &Value, count: u64) -> bool { + let hits: Vec<&str> = value["hits"] + .as_array() + .map(|a| a.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let distinct: std::collections::HashSet<_> = hits.iter().collect(); + assert_eq!( + distinct.len(), + hits.len(), + "a fact committed twice: {value}" + ); + hits.len() as u64 == count +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn single_node() { + let Some(celld) = celld_support::Celld::start_worker(&worker(), REQUIRED) else { + return; + }; + let base = celld.base(); + let long = Duration::from_secs(30); + + // Join, input, facts. + let mut ana = Player::connect(&base, "room-a").await; + let first = ticket("ana", "room-a"); + ana.hello(&first).await; + ana.welcome().await; + assert_eq!(ana.total(1).await, 0); + let active = until(&base, "/zone-activity/room-a", long, |v| { + status(v) == "active" + }) + .await; + assert_eq!(active["activity"]["restored"], false); + ana.input(&[1, 5]).await; + assert_eq!(ana.total(2).await, 5); + until(&base, "/tally/ana", long, |v| exactly_once(v, 5)).await; + + // A used ticket is refused. + let mut replay = Player::connect(&base, "room-a").await; + replay.hello(&first).await; + assert_eq!(replay.refused().await, error::UNAUTHORIZED); + + // Empty: dormant, then hibernated (dropped from memory). + ana.send(ClientMessage::Leave).await; + until(&base, "/zone-activity/room-a", long, |v| { + status(v) == "dormant" + }) + .await; + until(&base, "/zone-activity/room-a", long, |v| { + status(v) == "hibernated" + }) + .await; + let info = get(&base, "/rooms/room-a/info").await; + assert_eq!(info["awake"], false, "{info}"); + + // Wake with state. + let mut ana = Player::connect(&base, "room-a").await; + ana.hello(&ticket("ana", "room-a")).await; + ana.welcome().await; + assert_eq!(ana.total(1).await, 5, "restored from the snapshot"); + let woke = until(&base, "/zone-activity/room-a", long, |v| { + status(v) == "active" + }) + .await; + assert_eq!(woke["activity"]["restored"], true); + assert!(woke["activity"]["activations"].as_u64().unwrap() >= 2); + + // A doorway: handoff to room-b with a single-use transfer ticket. + ana.input(&[2, b'b']).await; + let transfer = ana + .expect(long, "Handoff", |got| match got { + Got::Server(ServerMessage::Handoff { + target_zone, + transfer_ticket, + }) => { + assert_eq!(target_zone, "room-b"); + Some(transfer_ticket.clone()) + } + _ => None, + }) + .await; + let mut ana_b = Player::connect(&base, "room-b").await; + ana_b.hello(&transfer).await; + ana_b.welcome().await; + let mut again = Player::connect(&base, "room-b").await; + again.hello(&transfer).await; + assert_eq!(again.refused().await, error::UNAUTHORIZED); + ana_b.input(&[1, 3]).await; + assert_eq!(ana_b.total(2).await, 3); + until(&base, "/tally/ana", long, |v| exactly_once(v, 8)).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fleet() { + let Some(mut fleet) = + fleet::Fleet::start(&worker(), 3, &[("CELLD_ZONE_NODES", "{nodes}")], REQUIRED) + else { + return; + }; + let long = Duration::from_secs(60); + // First touch through node-a places the zone (celld: first touch). + let a = fleet.base(0); + let mut probe = Player::connect(&a, "room-x").await; + probe.hello(&ticket("probe", "room-x")).await; + probe.welcome().await; + let info = until(&a, "/rooms/room-x/info", long, |v| { + v["node"].as_str().is_some_and(|n| !n.is_empty()) + }) + .await; + let owner = fleet + .index(info["node"].as_str().unwrap()) + .expect("known node"); + probe.send(ClientMessage::Leave).await; + // Empty: the zone writes its snapshot and goes dormant. + until(&a, "/zone-activity/room-x", long, |v| { + status(v) == "dormant" + }) + .await; + let via = (owner + 1) % 3; + let rejoin_via = (owner + 2) % 3; + + // Play through a node that does not own the zone (forwarded). + let mut bo = Player::connect(&fleet.base(via), "room-x").await; + bo.hello(&ticket("bo", "room-x")).await; + bo.welcome().await; + bo.input(&[1, 200]).await; + assert_eq!(bo.total(2).await, 200); + // Crash the owner once the 200 facts are in the zone's outbox (written + // right after the tick that emitted them); some may already be + // committed, most are in flight. + until(&a, "/rooms/room-x/info", long, |v| { + v["spooledFacts"].as_u64().unwrap_or(0) >= 200 + }) + .await; + fleet.kill(owner); + let closed = bo + .expect(Duration::from_secs(30), "the close", |got| match got { + Got::Closed(code) => Some(*code), + _ => None, + }) + .await; + assert_eq!( + closed, + Some(u16::from(CloseCode::Restart)), + "a forwarded socket of a crashed zone closes with 1012" + ); + + // Rejoin through the third node: celld recovers the zone elsewhere. + let base = fleet.base(rejoin_via); + let deadline = Instant::now() + long; + let (_bo, restored) = loop { + let mut bo = match Player::try_connect(&base, "room-x").await { + Ok(bo) => bo, + // celld answers 500 until the dead owner's lease expires. + Err(_) => { + assert!(Instant::now() < deadline, "the zone never came back"); + tokio::time::sleep(Duration::from_millis(500)).await; + continue; + } + }; + bo.hello(&ticket("bo", "room-x")).await; + let got = bo + .expect( + Duration::from_secs(20), + "Welcome or Error", + |got| match got { + Got::Server(ServerMessage::Welcome(_)) => Some(true), + Got::Server(ServerMessage::Error { .. }) => Some(false), + Got::Closed(_) => Some(false), + _ => None, + }, + ) + .await; + if got { + let total = bo.total(1).await; + break (bo, total); + } + assert!(Instant::now() < deadline, "the zone never came back"); + tokio::time::sleep(Duration::from_millis(500)).await; + }; + assert!(restored <= 200, "restored total {restored}"); + // Every hit the zone emitted reaches the cell exactly once. + until(&base, "/tally/bo", long, |v| exactly_once(v, 200)).await; + let activity = until(&base, "/zone-activity/room-x", long, |v| { + status(v) == "active" && v["activity"]["restored"] == true + }) + .await; + assert_ne!( + activity["activity"]["node"].as_str().unwrap_or(""), + fleet.nodes[owner].name, + "recovered on a live node" + ); +} diff --git a/distributed_game/tests/support/celld.rs b/distributed_game/tests/support/celld.rs index 1efd2fff..03d2ce17 100644 --- a/distributed_game/tests/support/celld.rs +++ b/distributed_game/tests/support/celld.rs @@ -26,13 +26,23 @@ pub struct Celld { impl Celld { pub fn start_new() -> Option { - let build = worker_dir().join("build"); + Self::start_worker(&worker_dir(), "CELLD_FACTS_REQUIRED") + } + + /// `celld dev` serving the built worker in `dir`; skips (None) when it + /// is not built or celld cannot start, unless env `required` is set. + pub fn start_worker(dir: &Path, required: &str) -> Option { + let build = dir.join("build"); if !build.join("worker/shim.mjs").exists() { assert!( - std::env::var_os("CELLD_FACTS_REQUIRED").is_none(), - "CELLD_FACTS_REQUIRED is set but the fact worker is not built" + std::env::var_os(required).is_none(), + "{required} is set but {} is not built", + dir.display() + ); + eprintln!( + "skipping: build {} first (worker-build --release)", + dir.display() ); - eprintln!("skipping celld fact routing: build tests/celld/fact-worker first"); return None; } let project = std::env::temp_dir().join(format!( @@ -44,11 +54,7 @@ impl Celld { .as_nanos() )); copy_dir(&build, &project.join("build")); - std::fs::copy( - worker_dir().join("wrangler.jsonc"), - project.join("wrangler.jsonc"), - ) - .unwrap(); + std::fs::copy(dir.join("wrangler.jsonc"), project.join("wrangler.jsonc")).unwrap(); let port = TcpListener::bind("127.0.0.1:0") .unwrap() .local_addr() @@ -62,10 +68,10 @@ impl Celld { }; if let Err(reason) = celld.start() { assert!( - std::env::var_os("CELLD_FACTS_REQUIRED").is_none(), + std::env::var_os(required).is_none(), "celld dev is required but did not start: {reason}" ); - eprintln!("skipping celld fact routing: {reason}"); + eprintln!("skipping: {reason}"); return None; } Some(celld) @@ -100,7 +106,10 @@ impl Celld { let log = std::fs::File::create(&log_path).unwrap(); let child = Command::new(&binary) .args(&args) - .env("RUST_LOG", "warn") + .env( + "RUST_LOG", + std::env::var("CELLD_RUST_LOG").unwrap_or_else(|_| "warn".into()), + ) .stdin(Stdio::null()) .stdout(log.try_clone().unwrap()) .stderr(log) @@ -200,7 +209,7 @@ impl Drop for Celld { } } -fn copy_dir(from: &Path, to: &Path) { +pub fn copy_dir(from: &Path, to: &Path) { std::fs::create_dir_all(to).unwrap(); for entry in std::fs::read_dir(from).unwrap() { let entry = entry.unwrap(); @@ -213,7 +222,7 @@ fn copy_dir(from: &Path, to: &Path) { } } -fn blocking_get(url: &str) -> Option { +pub fn blocking_get(url: &str) -> Option { let url = url.strip_prefix("http://")?; let (host, path) = url.split_once('/')?; let mut stream = std::net::TcpStream::connect(host).ok()?; diff --git a/distributed_game/tests/support/fleet.rs b/distributed_game/tests/support/fleet.rs new file mode 100644 index 00000000..9eb7231b --- /dev/null +++ b/distributed_game/tests/support/fleet.rs @@ -0,0 +1,308 @@ +//! A local multi-node celld fleet owned by one test: one RustFS container +//! (docker compose, its own project and port) holding the fleet bucket, the +//! worker deployed to it with `celld deploy`, and N `celld` nodes in +//! production mode. Kills only what it started; removes the container on +//! drop. Skips (None) without docker / aws / a built worker unless env +//! `required` is set. + +#![allow(dead_code)] + +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use super::celld_support::{blocking_get, copy_dir}; + +const ACCESS_KEY: &str = "fleettest"; +const SECRET_KEY: &str = "fleettestsecret123"; + +fn free_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +pub struct Node { + pub name: String, + pub public: u16, + pub operator: u16, + child: Option, +} + +pub struct Fleet { + dir: PathBuf, + project: String, + s3_port: u16, + bucket: String, + binary: String, + pub nodes: Vec, +} + +fn skip(required: &str, reason: String) -> Option { + assert!( + std::env::var_os(required).is_none(), + "{required} is set but the fleet is unavailable: {reason}" + ); + eprintln!("skipping the celld fleet test: {reason}"); + None +} + +impl Fleet { + /// Start `nodes` nodes serving the worker in `worker` with extra + /// string `vars` (`{nodes}` in a value is replaced by the operator + /// listeners, `node-a=127.0.0.1:p,…`). + pub fn start( + worker: &Path, + nodes: usize, + vars: &[(&str, &str)], + required: &str, + ) -> Option { + if !worker.join("build/worker/shim.mjs").exists() { + return skip(required, format!("build {} first", worker.display())); + } + for tool in ["docker", "aws"] { + if Command::new(tool).arg("--version").output().is_err() { + return skip(required, format!("`{tool}` is not available")); + } + } + let binary = std::env::var("CELLD_BIN").unwrap_or_else(|_| "celld".into()); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + let dir = std::env::temp_dir().join(format!( + "distributed-celld-fleet-{}-{stamp}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut fleet = Self { + project: format!("dg-fleet-{}-{}", std::process::id(), stamp % 100_000), + s3_port: free_port(), + bucket: "dg-fleet".into(), + binary, + nodes: Vec::new(), + dir, + }; + for i in 0..nodes { + fleet.nodes.push(Node { + name: format!("node-{}", (b'a' + i as u8) as char), + public: free_port(), + operator: free_port(), + child: None, + }); + } + if let Err(reason) = fleet.s3_up() { + return skip(required, reason); + } + let listeners = fleet + .nodes + .iter() + .map(|n| format!("{}=127.0.0.1:{}", n.name, n.operator)) + .collect::>() + .join(","); + let vars: Vec<(String, String)> = vars + .iter() + .map(|(k, v)| (k.to_string(), v.replace("{nodes}", &listeners))) + .collect(); + if let Err(reason) = fleet.deploy(worker, &vars) { + return skip(required, reason); + } + for i in 0..fleet.nodes.len() { + if let Err(reason) = fleet.start_node(i) { + return skip(required, reason); + } + } + Some(fleet) + } + + fn env(&self, command: &mut Command) { + command + .env("AWS_ACCESS_KEY_ID", ACCESS_KEY) + .env("AWS_SECRET_ACCESS_KEY", SECRET_KEY) + .env("AWS_REGION", "us-east-1") + .env("S3_ENDPOINT", self.endpoint()); + } + + fn endpoint(&self) -> String { + format!("http://127.0.0.1:{}", self.s3_port) + } + + fn s3_up(&self) -> Result<(), String> { + let compose = self.dir.join("s3-compose.yaml"); + std::fs::write( + &compose, + format!( + "services:\n s3:\n image: rustfs/rustfs:1.0.3\n command: [\"/data\"]\n environment:\n RUSTFS_ADDRESS: \"0.0.0.0:9000\"\n RUSTFS_ACCESS_KEY: {ACCESS_KEY}\n RUSTFS_SECRET_KEY: {SECRET_KEY}\n ports: [\"127.0.0.1:{}:9000\"]\n", + self.s3_port + ), + ) + .unwrap(); + let up = Command::new("docker") + .args(["compose", "-p", &self.project, "-f"]) + .arg(&compose) + .args(["up", "-d"]) + .output() + .map_err(|e| format!("docker compose: {e}"))?; + if !up.status.success() { + return Err(format!( + "docker compose up: {}", + String::from_utf8_lossy(&up.stderr) + )); + } + let deadline = Instant::now() + Duration::from_secs(60); + loop { + let mut mb = Command::new("aws"); + self.env(&mut mb); + let made = mb + .args(["--endpoint-url", &self.endpoint(), "s3", "mb"]) + .arg(format!("s3://{}", self.bucket)) + .output() + .map_err(|e| format!("aws: {e}"))?; + let text = String::from_utf8_lossy(&made.stderr); + if made.status.success() || text.contains("BucketAlready") { + return Ok(()); + } + if Instant::now() > deadline { + return Err(format!("bucket: {text}")); + } + std::thread::sleep(Duration::from_millis(500)); + } + } + + fn bucket_url(&self) -> String { + format!("s3://{}/fleet", self.bucket) + } + + fn deploy(&self, worker: &Path, vars: &[(String, String)]) -> Result<(), String> { + let project = self.dir.join("worker"); + copy_dir(&worker.join("build"), &project.join("build")); + let config = std::fs::read_to_string(worker.join("wrangler.jsonc")).unwrap(); + let mut config: serde_json::Value = serde_json::from_str(&strip_comments(&config)) + .map_err(|e| format!("wrangler.jsonc: {e}"))?; + for (key, value) in vars { + config["vars"][key] = serde_json::Value::String(value.clone()); + } + std::fs::write(project.join("wrangler.json"), config.to_string()).unwrap(); + let mut deploy = Command::new(&self.binary); + self.env(&mut deploy); + let out = deploy + .arg("deploy") + .arg(&project) + .args(["--bucket", &self.bucket_url()]) + .output() + .map_err(|e| format!("celld deploy: {e}"))?; + if !out.status.success() { + return Err(format!( + "celld deploy: {}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + )); + } + Ok(()) + } + + /// (Re)start node `i`. + pub fn start_node(&mut self, i: usize) -> Result<(), String> { + let node = &self.nodes[i]; + let dir = self.dir.join(&node.name); + std::fs::create_dir_all(&dir).unwrap(); + let log = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join("celld.log")) + .unwrap(); + let mut command = Command::new(&self.binary); + self.env(&mut command); + let child = command + .args(["--bucket", &self.bucket_url()]) + .args(["--listen", &format!("127.0.0.1:{}", node.public)]) + .args(["--internal-listen", &format!("127.0.0.1:{}", node.operator)]) + .args(["--advertise", &format!("127.0.0.1:{}", node.operator)]) + .env("CELLD_NODE", &node.name) + .env("CELLD_WATCH", dir.join("watch")) + .env("CELLD_READY_FLEET_GATE_MS", "0") + .env( + "RUST_LOG", + std::env::var("CELLD_RUST_LOG").unwrap_or_else(|_| "warn".into()), + ) + .stdin(Stdio::null()) + .stdout(log.try_clone().unwrap()) + .stderr(log) + .spawn() + .map_err(|e| format!("celld: {e}"))?; + let public = node.public; + self.nodes[i].child = Some(child); + let deadline = Instant::now() + Duration::from_secs(90); + while Instant::now() < deadline { + if blocking_get(&format!("http://127.0.0.1:{public}/health")) == Some(200) { + return Ok(()); + } + let exited = self.nodes[i] + .child + .as_mut() + .and_then(|c| c.try_wait().ok().flatten()); + if let Some(status) = exited { + // A cold node sometimes fails its first start; retry once. + self.nodes[i].child = None; + return Err(format!( + "{} exited ({status}); logs in {}", + self.nodes[i].name, + dir.display() + )); + } + std::thread::sleep(Duration::from_millis(250)); + } + Err(format!( + "{} not ready; logs in {}", + self.nodes[i].name, + dir.display() + )) + } + + /// `http://127.0.0.1:{public port}` of node `i`. + pub fn base(&self, i: usize) -> String { + format!("http://127.0.0.1:{}", self.nodes[i].public) + } + + /// Index of the node named `name`. + pub fn index(&self, name: &str) -> Option { + self.nodes.iter().position(|n| n.name == name) + } + + /// SIGKILL node `i` (a crash). + pub fn kill(&mut self, i: usize) { + if let Some(mut child) = self.nodes[i].child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +impl Drop for Fleet { + fn drop(&mut self) { + for i in 0..self.nodes.len() { + self.kill(i); + } + let _ = Command::new("docker") + .args(["compose", "-p", &self.project, "-f"]) + .arg(self.dir.join("s3-compose.yaml")) + .args(["down", "-v"]) + .output(); + if std::thread::panicking() { + eprintln!("fleet logs kept in {}", self.dir.display()); + } else { + let _ = std::fs::remove_dir_all(&self.dir); + } + } +} + +/// Drop `//` line comments (wrangler.jsonc). +fn strip_comments(text: &str) -> String { + text.lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n") +} diff --git a/distributed_game/tests/zone_status/main.rs b/distributed_game/tests/zone_status/main.rs index 18e6ffa1..fc0b1c11 100644 --- a/distributed_game/tests/zone_status/main.rs +++ b/distributed_game/tests/zone_status/main.rs @@ -230,3 +230,155 @@ async fn cell_events_project_into_zone_status_rows() { assert_eq!(of(status("z-never").await), ZoneStatus::Cold); let _ = std::fs::remove_dir_all(dir); } + +/// A celld-hosted zone's lifecycle reports (`zone_activity.record` facts) +/// project into `zone_activities`; a report emitted earlier but settled +/// later never overwrites a newer one; only the zone itself may report. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn zone_activity_reports_project_into_zone_status() { + use distributed_game::cell::trusted_session; + use distributed_game::world::zone_activity::{ + mount_zone_activity, zone_principal, RecordActivity, ZoneActivity, RECORD, + }; + use distributed_game::world::zone_status::{ + activity_projector_for, ZoneActivities, ZONE_ACTIVITIES, + }; + use distributed_game::{Fact, FactStamp}; + + let clock = ManualClock::new(SystemTime::UNIX_EPOCH + Duration::from_millis(T0)); + let cell = mount_zone_activity(cell::("ZoneActivity", "pit.0.1", &clock)); + let report = |status: &str, seq: u64, principal: &str| { + let fact = Fact::trusted( + format!("zone_activity.record:pit.0.1:9:{seq}"), + RECORD, + serde_json::to_value(RecordActivity { + status: status.into(), + activation: "act-1".into(), + restored: status == "active" && seq > 1, + players: u32::from(status == "active"), + node: "node-b".into(), + isolate: "iso".into(), + }) + .unwrap(), + Some(principal.to_string()), + FactStamp { + zone_id: "pit.0.1".into(), + activation: 9, + seq, + emitted_at_ms: T0 + seq, + ..FactStamp::default() + }, + ); + (fact.clone(), trusted_session(principal, &fact)) + }; + let send = |status: &'static str, seq: u64, principal: &'static str| { + let cell = &cell; + async move { + let (fact, session) = report(status, seq, principal); + let identity = CellCommandIdentity::new( + "celld-zone", + principal, + command_id_for("celld-zone", fact.key()), + ) + .unwrap(); + cell.dispatch_idempotent(RECORD, &identity, fact.input().clone(), session) + .await + } + }; + let me = Box::leak(zone_principal("pit.0.1").into_boxed_str()); + assert_eq!( + send("active", 1, me).await.unwrap().payload()["applied"], + true + ); + assert_eq!( + send("hibernated", 3, me).await.unwrap().payload()["applied"], + true + ); + // Emitted before `hibernated`, settled after it: ignored. + let late = send("dormant", 2, me).await.unwrap(); + assert_eq!(late.payload()["applied"], false); + assert_eq!(late.payload()["status"], "hibernated"); + assert!( + send("active", 4, "guest-x").await.is_err(), + "only the zone reports" + ); + assert_eq!(cell.load().await.unwrap().unwrap().status(), "hibernated"); + + let dir = std::env::temp_dir().join(format!( + "zone-activity-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let bus_url = format!("sqlite:{}?mode=rwc", dir.join("bus.db").display()); + let pool = sqlx::SqlitePool::connect(&bus_url).await.unwrap(); + let bus = SqliteBus::new(pool.clone()); + bus.ensure_tables().await.unwrap(); + let publisher = BusPublisher::new(Arc::new(bus)); + let mut messages = cell.durable_outbox().unwrap(); + messages.sort_by_key(|m| m.created_at); + assert_eq!(messages.len(), 2, "one event per applied report"); + for message in messages { + publisher.publish(Message::from(message)).await.unwrap(); + } + let read_url = format!("sqlite:{}?mode=rwc", dir.join("read.db").display()); + let repo = SqliteRepository::connect_and_migrate(&read_url) + .await + .unwrap(); + repo.bootstrap_table_schema_for_dev(&read_model_catalog().table_registry().unwrap()) + .await + .unwrap(); + let read_models = repo.clone(); + async fn project( + context: CausalProjectorContext, + projection: ModeledProjection, + ) -> Result<(), HandlerError> { + projection.apply(ZONE_ACTIVITIES, &context).await + } + // A consumer of its own (a minimap service would be one too). + spawn_service_consumer_loop(move || { + Service::new() + .named("zone-minimap") + .routes( + Routes::new() + .with_read_model_store(read_models.clone()) + .modeled_projector(activity_projector_for("zone-minimap")) + .handle(project), + ) + .with_bus(SqliteBus::new(pool.clone()).with_idle_poll(CONSUMER_IDLE_POLL)) + }); + let start = Instant::now(); + let row = loop { + let row = sqlx::query_as::<_, (String, String, String, i64)>( + "SELECT zone_id, status, node, activations FROM zone_activities WHERE zone_id = ?", + ) + .bind("pit.0.1") + .fetch_optional(repo.pool()) + .await + .unwrap(); + if let Some(row) = row.filter(|row| row.1 == "hibernated") { + break row; + } + assert!( + start.elapsed() < Duration::from_secs(20), + "projection timed out" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + }; + assert_eq!(row.2, "node-b"); + assert_eq!(row.3, 1); + let activity = ZoneActivities { + zone_id: row.0, + status: row.1, + node: row.2, + ..ZoneActivities::default() + }; + assert_eq!( + ZoneStatus::of_activity(Some(&activity)), + ZoneStatus::Hibernated { bytes: 0 } + ); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/js/src/game/connection.ts b/js/src/game/connection.ts index bd7c79ac..61a12614 100644 --- a/js/src/game/connection.ts +++ b/js/src/game/connection.ts @@ -10,6 +10,7 @@ import { ClockSync, type ClockSyncOptions } from './clock.js'; import { Emitter } from './emitter.js'; import { encodeClientMessage, + GAME_CLOSE_RESTARTED, GAME_PROTOCOL_VERSION, GameClientMirror, GameErrorCode, @@ -643,6 +644,11 @@ export class GameConnection { this.#disconnectedAt = this.#scheduler.now(); this.#failedAttempts = 0; } + if (code === GAME_CLOSE_RESTARTED) { + // The zone restarted or moved: its sessions are gone. Rejoin now + // with a fresh ticket instead of presenting a void token. + this.#reconnectToken = ''; + } if (attempt.redirect !== undefined) { this.#redirects += 1; if (this.#redirects > (this.#options.maxRedirects ?? 3)) { diff --git a/js/src/game/index.ts b/js/src/game/index.ts index 271131bc..05e9b39e 100644 --- a/js/src/game/index.ts +++ b/js/src/game/index.ts @@ -45,6 +45,7 @@ export { type GameSchema } from './schema.js'; export { + GAME_CLOSE_RESTARTED, GAME_PROTOCOL_VERSION, GameClientMirror, GameErrorCode, diff --git a/js/src/game/messages.ts b/js/src/game/messages.ts index 685afb64..b414bceb 100644 --- a/js/src/game/messages.ts +++ b/js/src/game/messages.ts @@ -45,6 +45,13 @@ export const GameKickCode = Object.freeze({ RELOAD: 7 }); +/** + * WebSocket close code of a zone that restarted or moved (a celld zone + * object woke up again, its node stopped or crashed): the reconnect token is + * void, the client rejoins at once with a fresh ticket. + */ +export const GAME_CLOSE_RESTARTED = 1012; + /** Error codes. */ export const GameErrorCode = Object.freeze({ BAD_MESSAGE: 1, diff --git a/js/tests/game-connection.test.mjs b/js/tests/game-connection.test.mjs index 7da338af..a678d3fc 100644 --- a/js/tests/game-connection.test.mjs +++ b/js/tests/game-connection.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + GAME_CLOSE_RESTARTED, GAME_PROTOCOL_VERSION, GameClient, GameConnection, @@ -214,6 +215,25 @@ test('reconnects with the token inside the grace window, then falls back to a fr conn.close(); }); +test('a zone that restarted (close 1012) is rejoined at once with a fresh ticket', async () => { + const network = new FakeNetwork(); + const scheduler = new FakeScheduler(); + scriptedServer(network); + const { conn, tickets } = connection(network, scheduler, { + reconnect: { graceMs: 15_000, maxAttempts: 100, backoffMs: () => 1000 } + }); + await conn.connect(); + assert.equal(conn.reconnectToken, 'rt-1'); + network.last().close(GAME_CLOSE_RESTARTED); + await scheduler.advance(10); + const hello = network.last().lastHello; + assert.equal(hello.reconnectToken, '', 'the old session is gone: no token'); + assert.equal(hello.ticket, 't:z1:2'); + assert.deepEqual(tickets.at(-1), { zoneId: 'z1', role: 'player', reason: 'rejoin' }); + assert.equal(conn.state, 'open'); + conn.close(); +}); + test('kick and handshake rejection close without reconnecting', async () => { const network = new FakeNetwork(); const scheduler = new FakeScheduler(); diff --git a/scripts/check-wasm32-domains.sh b/scripts/check-wasm32-domains.sh index bd35cf11..452fd282 100755 --- a/scripts/check-wasm32-domains.sh +++ b/scripts/check-wasm32-domains.sh @@ -15,7 +15,9 @@ # tests/e2e-ui/crates/*-domain, examples/game/server/crawler, # game_services/crates/*-domain, game_services/crates/kernel # plus the framework crates domains depend on: distributed (no features) and -# distributed_game (no features; the FactCommand derive). +# distributed_game (no features; the FactCommand derive), and distributed_game +# with `celld-zone` (the celld zone host: zones as Durable Objects), plus the +# game zones hosted that way (examples/game/server/rooms). # # Excluded, with the reason (a crate here never runs in a cell): # tests/e2e-ui/crates/blob-domain e2e-ui blob game host: its `domain` @@ -34,6 +36,7 @@ manifests() { echo distributed_game/Cargo.toml for manifest in tests/e2e-ui/crates/*-domain/Cargo.toml \ examples/game/server/crawler/Cargo.toml \ + examples/game/server/rooms/Cargo.toml \ game_services/crates/*-domain/Cargo.toml \ game_services/crates/kernel/Cargo.toml; do case "$manifest" in @@ -65,6 +68,14 @@ for manifest in $(manifests); do fi done +# The celld zone host is wasm32 code by design. +printf '==> wasm32 distributed_game/Cargo.toml --features celld-zone\n' +if ! "$cargo" build --quiet --lib --target "$target" --manifest-path distributed_game/Cargo.toml \ + --target-dir "$target_dir" --no-default-features --features celld-zone; then + printf 'FAIL: distributed_game with celld-zone does not build for %s\n' "$target" >&2 + failed=1 +fi + fixture=tests/fixtures/wasm32-native-domain/Cargo.toml printf '==> negative fixture %s (must fail)\n' "$fixture" if "$cargo" build --quiet --lib --target "$target" --manifest-path "$fixture" \ diff --git a/tests/celld/README.md b/tests/celld/README.md index 7c04021b..675b7dd3 100644 --- a/tests/celld/README.md +++ b/tests/celld/README.md @@ -211,3 +211,20 @@ from the full Queue/NATS/browser profile. Every non-health aggregate route requires `DISTRIBUTED_INTERNAL_SECRET`. The checked-in value is loopback test data only; production requires a separately provisioned secret plus the normal network policy. + +## Zones as celld objects + +`zone-worker/` hosts a small tally zone with `distributed_game`'s +`celld-zone` feature (one Durable Object per zone, `celld_zone_object!`), +its `Tally` cells and the `ZoneActivity` cell. `distributed_game`'s +`celld_zone` test drives it over WebSockets: on `celld dev` (join, input, +facts, dormant → hibernated, wake with state, single-use tickets, a +doorway handoff) and on a three-node production-mode fleet on one RustFS +bucket started with docker compose (a forwarded socket closes with 1012 +when the owner node is killed, the zone recovers on another node, and 200 +in-flight facts reach the cell exactly once): + +```sh +(cd tests/celld/zone-worker && worker-build --release) +CELLD_BIN=… CELLD_ESBUILD=… cargo test -p distributed_game --features celld,net --test celld_zone +``` diff --git a/tests/celld/zone-worker/.gitignore b/tests/celld/zone-worker/.gitignore new file mode 100644 index 00000000..28ef4191 --- /dev/null +++ b/tests/celld/zone-worker/.gitignore @@ -0,0 +1,4 @@ +build/ +target/ +.celld/ +.dev.vars diff --git a/tests/celld/zone-worker/Cargo.toml b/tests/celld/zone-worker/Cargo.toml new file mode 100644 index 00000000..1fe2caf6 --- /dev/null +++ b/tests/celld/zone-worker/Cargo.toml @@ -0,0 +1,27 @@ +# A test zone hosted as celld Durable Objects (`distributed_game`'s +# `celld-zone` feature) with the cells its facts go to. Used by +# `distributed_game/tests/celld_zone` (single node and a multi-node fleet). +[workspace] +members = ["."] +resolver = "2" + +[package] +name = "celld-zone-test-worker" +version = "0.1.0" +edition = "2021" +publish = false +description = "workers-rs: a tally zone as a celld Durable Object, its Tally and ZoneActivity cells" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +console_error_panic_hook = "0.1" +distributed = { path = "../../..", default-features = false, features = ["workers-rs"] } +distributed_game = { path = "../../../distributed_game", default-features = false, features = ["celld-zone"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +worker = { version = "0.8", features = ["queue"] } + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/tests/celld/zone-worker/src/lib.rs b/tests/celld/zone-worker/src/lib.rs new file mode 100644 index 00000000..27538228 --- /dev/null +++ b/tests/celld/zone-worker/src/lib.rs @@ -0,0 +1,288 @@ +//! A test zone hosted by `distributed_game::celld_zone` and the cells its +//! facts go to. +//! +//! - `GET /rooms/{zone}` (WebSocket): `TallyRoom`, one object per zone +//! `room-*`. Input `[1, n]` counts `n` hits, each a `tally.hit` fact for +//! the player's `Tally` cell; `[2, c]` walks through a door into +//! `room-{c}` (handoff). On join the player gets event 1 with the zone's +//! total hits (u64 LE); after a hits input, event 2 with the new total. +//! The zone's snapshot is its total. +//! - `GET /rooms/{zone}/info`: the object's debug info. +//! - `GET|POST /tally/{principal}[/command]`, `/zone-activity/{zone}[/command]`: +//! the cells (internal secret). + +use distributed::cell_host::CelldOutbox; +use distributed::command::{typed_command, PreparedCommand, Succeeded}; +use distributed::microsvc::{ + CausalCommandContext, CausalRouteDependencies, HandlerError, PortableCommand, Routes, +}; +use distributed::{sourced, Entity}; +use distributed_game::cell::session_stamp; +use distributed_game::celld_zone::{ + authenticate, cell_alarm, error_response, fact_sessions, forward_to_zone, json_status, + serve_cell, CelldZone, ObjectEnv, ObjectFactSink, ZoneActivityCell, +}; +use distributed_game::command_sink::FactRoute; +use distributed_game::{ConsumePolicy, Ctx, EntitySchema, PlayerId, Zone}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use worker::*; + +// ---------------------------------------------------------------- zone + +/// One counted hit (the cell counts it; the zone reports which hit). +#[derive(Debug, Clone, Serialize, Deserialize, distributed_game::FactCommand)] +#[fact(command = "tally.hit")] +pub struct TallyHit { + /// The zone's running hit number. + pub hit: u64, + /// Zone it happened in. + pub zone: String, +} + +/// The test zone. +pub struct TallyZone { + total: u64, +} + +pub enum TallyInput { + Hits(u8), + Door(u8), +} + +impl Zone for TallyZone { + type Config = (); + type Input = TallyInput; + type Timer = (); + type Message = distributed_game::NoMessages; + + fn schema() -> EntitySchema { + EntitySchema::new() + } + + fn on_create(_ctx: &mut Ctx<'_, Self>, _config: ()) -> Self { + Self { total: 0 } + } + + fn on_join(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId) { + ctx.send_event(player, 1, self.total.to_le_bytes().to_vec()); + } + + fn decode_input(&self, _player: PlayerId, payload: &[u8]) -> Option { + match payload { + [1, n] => Some(TallyInput::Hits(*n)), + [2, c] if c.is_ascii_lowercase() => Some(TallyInput::Door(*c)), + _ => None, + } + } + + fn on_tick(&mut self, ctx: &mut Ctx<'_, Self>) { + let players: Vec = ctx.players().map(|p| p.id).collect(); + for player in players { + for input in ctx.take_inputs(player, ConsumePolicy::Drain) { + match input.input { + TallyInput::Hits(n) => { + for _ in 0..n { + self.total += 1; + let hit = TallyHit { + hit: self.total, + zone: ctx.zone_id().to_string(), + }; + let total = self.total; + let _ = ctx.emit_keyed(player, hit, &[&total]); + } + ctx.send_event(player, 2, self.total.to_le_bytes().to_vec()); + } + TallyInput::Door(c) => { + let target = format!("room-{}", c as char); + let _ = ctx.handoff(player, &target, Vec::new()); + } + } + } + } + } + + fn hibernate(&self) -> Option> { + Some(self.total.to_string().into_bytes()) + } + + fn on_restore(&mut self, _ctx: &mut Ctx<'_, Self>, state: &[u8], _saved_version: &str) { + self.total = std::str::from_utf8(state) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + } +} + +impl CelldZone for TallyZone { + fn object_config(zone_id: &str, _env: &ObjectEnv) -> std::result::Result<(), String> { + zone_id + .starts_with("room-") + .then_some(()) + .ok_or_else(|| format!("no such zone {zone_id}")) + } + + fn fact_routes(sink: ObjectFactSink) -> ObjectFactSink { + sink.route(FactRoute::by_principal::("tally")) + } +} + +distributed_game::celld_zone_object!(TallyRoom, TallyZone); + +// ---------------------------------------------------------------- Tally cell + +#[derive(Clone, Default, Serialize, Deserialize, distributed::Snapshot)] +pub struct Tally { + entity: Entity, + hits: Vec, +} + +#[sourced(entity, aggregate_type = "Tally", events = "TallyEvent")] +impl Tally { + #[event("tally.counted", version = 1)] + fn counted(&mut self, principal: String, hit: String) { + if self.entity.id().is_empty() { + self.entity.set_id(principal); + } + self.hits.push(hit); + } +} + +#[derive(Debug, Deserialize, distributed::CommandInput)] +pub struct HitInput { + pub hit: u64, + pub zone: String, +} + +#[derive(Debug, Serialize, distributed::CommandOutput)] +pub struct Counted { + pub count: u32, +} + +struct RecordHit; +impl PortableCommand for RecordHit +where + D: CausalRouteDependencies + Send + Sync + 'static, +{ + fn install(self, routes: Routes) -> Routes { + routes + .typed_command(typed_command::>("tally.hit")) + .handle(handle_hit) + } +} + +async fn handle_hit( + ctx: &CausalCommandContext<'_, Tally>, + input: HitInput, +) -> std::result::Result>, HandlerError> { + let principal = ctx.user_id()?.to_string(); + let stamp = session_stamp(ctx.session()) + .ok_or_else(|| HandlerError::Rejected("hits are facts".into()))?; + let repo = ctx.repo(); + let mut tally = match repo.get(&principal).await? { + Some(tally) => tally, + None => repo.create(), + }; + tally + .counted( + principal, + format!("{}#{}@{}", input.zone, input.hit, stamp.activation), + ) + .map_err(|e| HandlerError::Rejected(e.to_string()))?; + let count = tally.hits.len() as u32; + repo.commit(tally)?.succeeded(Counted { count }) +} + +async fn read_tally( + cell: &distributed::cell_host::AggregateCell, +) -> std::result::Result { + let tally = cell.load().await.map_err(|e| e.to_string())?; + let hits = tally.map(|t| t.hits).unwrap_or_default(); + Ok(json!({ "count": hits.len(), "hits": hits })) +} + +#[durable_object] +pub struct TallyCell { + cell: distributed::cell_host::AggregateCell, + env: Env, +} + +impl DurableObject for TallyCell { + fn new(state: State, env: Env) -> Self { + console_error_panic_hook::set_once(); + let cell = + distributed::cell_host::AggregateCell::::from_state_with_snapshots(state, 50) + .expect("tally cell identity") + .mount(RecordHit) + .with_celld_outbox(CelldOutbox::from_env(&env, "OUTBOX").expect("OUTBOX Queue")); + Self { cell, env } + } + + async fn fetch(&self, req: Request) -> Result { + serve_cell(&self.env, &self.cell, req, read_tally, fact_sessions).await + } + + async fn alarm(&self) -> Result { + cell_alarm(&self.env, &self.cell).await + } +} + +#[durable_object] +pub struct ZoneActivityObject { + cell: ZoneActivityCell, +} + +impl DurableObject for ZoneActivityObject { + fn new(state: State, env: Env) -> Self { + console_error_panic_hook::set_once(); + let outbox = CelldOutbox::from_env(&env, "OUTBOX").expect("OUTBOX Queue"); + Self { + cell: ZoneActivityCell::new(state, env, outbox), + } + } + + async fn fetch(&self, req: Request) -> Result { + self.cell.fetch(req).await + } + + async fn alarm(&self) -> Result { + self.cell.alarm().await + } +} + +// ---------------------------------------------------------------- entry + +#[event(fetch)] +async fn main(req: Request, env: Env, _ctx: Context) -> Result { + console_error_panic_hook::set_once(); + let path = req.url()?.path().to_string(); + if path == "/health" { + return Response::ok("ok"); + } + if path.starts_with("/rooms/") { + return match forward_to_zone(req, &env, "ROOM", "/rooms/").await? { + Some(response) => Ok(response), + None => Response::error("bad zone", 400), + }; + } + if let Err(error) = authenticate(&req, &env) { + return error_response(error); + } + let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect(); + let binding = match parts.first().copied() { + Some("tally") => "TALLY", + Some("zone-activity") => "ZONE_ACTIVITY", + _ => return json_status(json!({ "error": "unknown cell kind" }), 404), + }; + let Some(shard) = parts.get(1) else { + return json_status(json!({ "error": "missing shard" }), 400); + }; + let shard = worker::js_sys::decode_uri_component(shard) + .map(String::from) + .unwrap_or_else(|_| shard.to_string()); + let stub = env + .durable_object(binding)? + .id_from_name(&shard)? + .get_stub()?; + stub.fetch_with_request(req).await +} diff --git a/tests/celld/zone-worker/wrangler.jsonc b/tests/celld/zone-worker/wrangler.jsonc new file mode 100644 index 00000000..361dbb9e --- /dev/null +++ b/tests/celld/zone-worker/wrangler.jsonc @@ -0,0 +1,29 @@ +// The celld zone host test worker. Test-only secrets (the harness may +// override them in .dev.vars or a generated copy of this file). +{ + "name": "celld-zone-test", + "main": "build/worker/shim.mjs", + "compatibility_date": "2026-01-01", + "durable_objects": { + "bindings": [ + { "name": "ROOM", "class_name": "TallyRoom" }, + { "name": "TALLY", "class_name": "TallyCell" }, + { "name": "ZONE_ACTIVITY", "class_name": "ZoneActivityObject" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["TallyRoom", "TallyCell", "ZoneActivityObject"] } + ], + // The zone-activity cell publishes `zone.activity_changed` to this Queue + // (no consumer here; the tests read the cell). + "queues": { + "producers": [{ "binding": "OUTBOX", "queue": "celld-zone-test-outbox" }] + }, + "vars": { + "DISTRIBUTED_INTERNAL_SECRET": "test-only-internal-secret-change-me-2026", + "GAME_TICKET_SECRET": "test-only-ticket-secret-change-me-2026", + "ZONE_PERSIST_MS": "1000", + "ZONE_HIBERNATE_AFTER_MS": "2000", + "ZONE_RECONNECT_GRACE_MS": "1000" + } +} From d6ce837ececc48f7ad2e775f73027013a070dc4d Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 19:43:53 -0500 Subject: [PATCH 04/87] feat(game): bots on any zone path, readable zone info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bots append `{path}/{zone}` (default `/zones`), so they can join zones a celld worker routes elsewhere (`/rooms/{room}`). A celld zone object's `GET …/info` debug JSON allows any origin, so pages served elsewhere can show which node and isolate run the zone. [[tasks/gr-p43-celld-zone-host]] --- distributed_game/src/bots.rs | 15 +++++++++++++-- distributed_game/src/celld_zone/object.rs | 5 ++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/distributed_game/src/bots.rs b/distributed_game/src/bots.rs index 8f76e9a8..d5c3ac66 100644 --- a/distributed_game/src/bots.rs +++ b/distributed_game/src/bots.rs @@ -113,8 +113,13 @@ pub fn encode_i8_pair(dir: [f32; 2]) -> Vec { /// Bot run settings. #[derive(Clone)] pub struct BotConfig { - /// Server base URL, e.g. `ws://127.0.0.1:2567` (`/zones/{zone}` is appended). + /// Server base URL, e.g. `ws://127.0.0.1:2567` (`{path}/{zone}` is + /// appended). pub url: String, + /// Path the zone id is appended to: `/zones` on the `net` server, + /// whatever the worker routes to its zone objects on celld (e.g. + /// `/rooms`). + pub path: String, /// Zone to join. pub zone: String, /// Number of bots. @@ -145,6 +150,7 @@ impl BotConfig { pub fn new(url: impl Into, zone: impl Into, ticket_key: TicketKey) -> Self { Self { url: url.into(), + path: "/zones".into(), zone: zone.into(), bots: 10, input_rate: 20.0, @@ -364,7 +370,12 @@ async fn drive( name: None, extensions: Default::default(), }); - let url = format!("{}/zones/{}", config.url.trim_end_matches('/'), config.zone); + let url = format!( + "{}/{}/{}", + config.url.trim_end_matches('/'), + config.path.trim_matches('/'), + config.zone + ); let (mut ws, _) = tokio_tungstenite::connect_async(url.as_str()) .await .map_err(|e| format!("connect: {e}"))?; diff --git a/distributed_game/src/celld_zone/object.rs b/distributed_game/src/celld_zone/object.rs index 3180fc9c..9e347852 100644 --- a/distributed_game/src/celld_zone/object.rs +++ b/distributed_game/src/celld_zone/object.rs @@ -560,7 +560,10 @@ impl ZoneObject { } let path = req.url()?.path().to_string(); if path.ends_with("/info") { - return Response::from_json(&self.info()); + // Debug JSON without secrets; pages on other origins may read it. + let response = Response::from_json(&self.info())?; + response.headers().set("access-control-allow-origin", "*")?; + return Ok(response); } if req.headers().get("upgrade")?.as_deref() != Some("websocket") { return Response::error("expected a WebSocket upgrade", 426); From 28b187fb36d7232aa478e5dd60d026496723f8ea Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 19:44:25 -0500 Subject: [PATCH 05/87] feat(game)!: Mob Pit rooms as celld objects, doorways, room grid Mob Pit becomes a near-infinite seeded grid of rooms (`pit..`), each one a celld Durable Object hosted by distributed_game's celld zone host. celld decides where rooms run; nothing in the game names a node. - game_sim::mobpit: `pit_room` (#[game_pure], biome from the Endless Grid's noise, danger, 6-12 mobs), doorway and arrival rules, `PIT_WORLD`; the page draws rooms with the same Rust as WASM. - mobpit_rooms (new, wasm32-portable): the room zone (doorway handoffs with carried position, hibernation snapshot of the mobs and the room's kills, profile loaded from the Crawler cell after joining) and its CelldZone hooks (kill facts to crawler cells, lifecycle facts to presence cells, object to object). - cells worker: PitRoom objects behind `/rooms/{room}`, ZoneActivity cells feeding the zone status read model through the outbox Queue. - game-demos mobpit is the services side only (dev room tickets, relay, crawler directory, room status, boards fed from the directory with idempotent max updates). Removed: the native zone, its fact sink, the slow-storage knob and the in-memory presence mirror. - Vite page: rooms through `/rooms` (proxied to celld), doorways, biome floor, live minimap of room status, restore toasts, debug overlay with the celld node/isolate per room. run.sh starts celld dev itself; `run.sh mobpit --fleet [N]` runs N production-mode nodes on one RustFS bucket (docker compose) behind a round-robin dev balancer. Tests: mobpit_rooms reference-host tests; mobpit_smoke on celld (bots in a room, dormant -> hibernated -> restored, celld kill -9 mid-fight, presence cells); mobpit + leaderboard headless; pit_room WASM = native. [[tasks/gr-p41-mobpit-rooms-zones]] --- examples/game/cells/Cargo.toml | 8 +- examples/game/cells/src/lib.rs | 56 +- examples/game/cells/wrangler.jsonc | 25 +- examples/game/client/leaderboard/index.html | 7 +- .../client/src/generated/mobpit/constants.ts | 11 +- .../client/src/generated/mobpit/events.ts | 217 +-- .../game/client/src/generated/mobpit/index.ts | 1 + .../game/client/src/generated/mobpit/pures.ts | 60 + .../client/src/generated/mobpit/schema.ts | 24 +- .../client/src/generated/mobpit/surface.json | 392 +++-- examples/game/client/src/leaderboard/main.ts | 32 +- examples/game/client/src/mobpit/client.ts | 191 ++- examples/game/client/src/mobpit/main.ts | 212 ++- examples/game/client/src/mobpit/style.css | 58 + examples/game/client/tests/helpers/celld.mjs | 15 +- examples/game/client/tests/helpers/mobpit.mjs | 51 +- .../tests/leaderboard-headless.test.mjs | 28 +- .../client/tests/mobpit-headless.test.mjs | 110 +- .../client/tests/prediction-wasm.test.mjs | 4 +- examples/game/client/vite.config.ts | 6 +- examples/game/run.sh | 218 ++- examples/game/scripts/round-robin.mjs | 36 + examples/game/server/Cargo.lock | 164 +- examples/game/server/Cargo.toml | 5 +- examples/game/server/rooms/Cargo.toml | 28 + examples/game/server/rooms/src/celld.rs | 51 + examples/game/server/rooms/src/lib.rs | 27 + examples/game/server/rooms/src/zone.rs | 1392 +++++++++++++++++ examples/game/server/rooms/tests/rooms.rs | 164 ++ .../game/server/service/tests/projection.rs | 2 +- examples/game/server/sim/src/mobpit.rs | 245 ++- examples/game/server/src/common/mod.rs | 2 +- .../game/server/src/common/zone_status.rs | 64 +- examples/game/server/src/devcelld.rs | 42 +- examples/game/server/src/grid/dev.rs | 1 + examples/game/server/src/mobpit/board.rs | 98 +- examples/game/server/src/mobpit/mod.rs | 187 +-- examples/game/server/src/mobpit/presence.rs | 167 -- examples/game/server/src/mobpit/routes.rs | 169 +- examples/game/server/src/mobpit/store.rs | 249 +-- examples/game/server/src/mobpit/world.rs | 239 ++- examples/game/server/src/mobpit/zone.rs | 1374 +--------------- examples/game/server/src/predict_trace.rs | 12 + examples/game/server/tests/mobpit_smoke.rs | 603 +++---- 44 files changed, 4027 insertions(+), 3020 deletions(-) create mode 100644 examples/game/client/src/generated/mobpit/pures.ts create mode 100644 examples/game/scripts/round-robin.mjs create mode 100644 examples/game/server/rooms/Cargo.toml create mode 100644 examples/game/server/rooms/src/celld.rs create mode 100644 examples/game/server/rooms/src/lib.rs create mode 100644 examples/game/server/rooms/src/zone.rs create mode 100644 examples/game/server/rooms/tests/rooms.rs delete mode 100644 examples/game/server/src/mobpit/presence.rs diff --git a/examples/game/cells/Cargo.toml b/examples/game/cells/Cargo.toml index 65545b17..5bc8471c 100644 --- a/examples/game/cells/Cargo.toml +++ b/examples/game/cells/Cargo.toml @@ -1,5 +1,7 @@ # The game demos' durable cells on celld (workers-rs, wasm32): Mob Pit's -# per-player Crawler aggregate and per-player presence +# rooms (one Durable Object per room, `distributed_game::celld_zone`) and +# 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. @@ -22,8 +24,10 @@ crate-type = ["cdylib"] [dependencies] 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"] } +distributed_game = { path = "../../../distributed_game", default-features = false, features = ["cell", "workers-rs", "celld-zone"] } mobpit_crawler = { path = "../server/crawler" } +# Mob Pit's rooms, each one a Durable Object (`PitRoom`). +mobpit_rooms = { path = "../server/rooms", features = ["celld"] } serde = { version = "1", features = ["derive"] } serde_json = "1" worker = { version = "0.8", features = ["queue"] } diff --git a/examples/game/cells/src/lib.rs b/examples/game/cells/src/lib.rs index b7cfc59e..fe4505f4 100644 --- a/examples/game/cells/src/lib.rs +++ b/examples/game/cells/src/lib.rs @@ -1,6 +1,17 @@ -//! The game demos' durable cells on celld. Mob Pit's are fed by the zone -//! server's `distributed_game::celld::CelldFactSink` over the internal HTTP -//! path; every multi-host demo keeps its zone ownership and hibernation +//! The game demos' durable cells on celld, and Mob Pit's rooms. +//! +//! - `GET /rooms/{room}` (WebSocket) and `/rooms/{room}/info`: `PitRoom`, +//! one Durable Object per Mob Pit room (`pit..`), hosted by +//! `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-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. //! //! - `crawler/{principal}` (`CrawlerCell`): the `mobpit_crawler::Crawler` @@ -47,9 +58,11 @@ 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::lifecycle::{ Lifecycle, CONNECTION_LOST, PLAYER_DISCONNECTED, PLAYER_JOINED, PLAYER_LEFT, PLAYER_RECONNECTED, }; +use distributed_game::world::zone_activity::ZONE_ACTIVITY_KIND; use distributed_game::world::zone_lease::{mount_zone_lease, ZoneLease}; use distributed_game::world::zone_state::{celld_worker, mount_zone_state, ZoneState}; use distributed_game::FactStamp; @@ -715,14 +728,48 @@ impl DurableObject for ZoneStateCell { } } +// ---------------------------------------------------------------- Rooms + +distributed_game::celld_zone_object!(PitRoom, mobpit_rooms::MobPit); + +#[durable_object] +pub struct ZoneActivityObject { + cell: ZoneActivityCell, +} + +impl DurableObject for ZoneActivityObject { + fn new(state: State, env: Env) -> Self { + console_error_panic_hook::set_once(); + let outbox = outbox(&env); + Self { + cell: ZoneActivityCell::new(state, env, outbox), + } + } + + async fn fetch(&self, req: Request) -> Result { + self.cell.fetch(req).await + } + + async fn alarm(&self) -> Result { + self.cell.alarm().await + } +} + #[event(fetch)] async fn main(req: Request, env: Env, _ctx: Context) -> Result { console_error_panic_hook::set_once(); let url = req.url()?; - let path = url.path(); + let path = url.path().to_string(); if path == "/" || path == "/health" { return Response::ok("mobpit cells\n"); } + // A room is public: its WebSocket admits signed tickets only. + if path.starts_with("/rooms/") { + return match forward_to_zone(req, &env, "PIT_ROOM", "/rooms/").await? { + Some(response) => Ok(response), + None => json_status(json!({ "error": "no such room" }), 404), + }; + } if let Err(error) = authenticate(&req, &env) { return error_response(error); } @@ -734,6 +781,7 @@ async fn main(req: Request, env: Env, _ctx: Context) -> Result { Some("content-log") => "CONTENT_LOG", Some("zone-lease") => "ZONE_LEASE", Some("zone-state") => "ZONE_STATE", + Some(ZONE_ACTIVITY_KIND) => "ZONE_ACTIVITY", _ => return json_status(json!({ "error": "unknown cell kind" }), 404), }; let Some(shard) = parts.get(1) else { diff --git a/examples/game/cells/wrangler.jsonc b/examples/game/cells/wrangler.jsonc index c78fe9da..e18bdd9b 100644 --- a/examples/game/cells/wrangler.jsonc +++ b/examples/game/cells/wrangler.jsonc @@ -1,7 +1,8 @@ -// The game demos' cells for `celld dev` (and `celld deploy`). The launcher -// (`examples/game/run.sh`, the test harnesses) writes `.dev.vars` beside a -// copy of this file with DISTRIBUTED_INTERNAL_SECRET and -// CELLD_QUEUE_RELAY_URL (the zone server's `/internal/celld-queue/relay`). +// The game demos' cells and Mob Pit's rooms for `celld dev` (and `celld +// deploy`). The launcher (`examples/game/run.sh`, the test harnesses) writes +// `.dev.vars` beside a copy of this file with DISTRIBUTED_INTERNAL_SECRET, +// GAME_TICKET_SECRET (shared with whoever mints room tickets) and +// CELLD_QUEUE_RELAY_URL (the services' `/internal/celld-queue/relay`). { "name": "game-demo-cells", "main": "build/worker/shim.mjs", @@ -13,13 +14,16 @@ { "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": "CONTENT_LOG", "class_name": "ContentLogCell" }, + { "name": "PIT_ROOM", "class_name": "PitRoom" }, + { "name": "ZONE_ACTIVITY", "class_name": "ZoneActivityObject" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["CrawlerCell", "PresenceCell"] }, { "tag": "v2", "new_sqlite_classes": ["ZoneLeaseCell", "ZoneStateCell"] }, - { "tag": "v3", "new_sqlite_classes": ["GemPouchCell", "ContentLogCell"] } + { "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": [ @@ -41,6 +45,13 @@ ] }, "vars": { - "OUTBOX_DRAIN_INTERVAL_MS": "5000" + "OUTBOX_DRAIN_INTERVAL_MS": "5000", + // Mob Pit rooms (distributed_game::celld_zone). GAME_TICKET_SECRET comes + // from .dev.vars (the launcher shares it with the ticket issuer). + "ZONE_TICK_HZ": "30", + "ZONE_PATCH_HZ": "20", + "ZONE_PERSIST_MS": "5000", + "ZONE_HIBERNATE_AFTER_MS": "10000", + "ZONE_FACT_SERVICE": "mobpit-rooms" } } diff --git a/examples/game/client/leaderboard/index.html b/examples/game/client/leaderboard/index.html index 05d404db..a2b5d669 100644 --- a/examples/game/client/leaderboard/index.html +++ b/examples/game/client/leaderboard/index.html @@ -12,8 +12,9 @@

Mob Pit leaderboard

- Kill facts committed to the Crawler cells are projected into two sorted-set boards (fire-and-forget, - idempotent apply keys). The top 10 is pushed over the zone connection (observer); your rank is read on each push. + Kills settle in the Crawler cells next to the rooms on celld; their events are projected into the crawler list + and from there into two sorted-set boards (idempotent `max` updates). The top 10 refreshes live; your rank is + read on each change. Play the pit ↗

@@ -24,7 +25,7 @@

Mob Pit leaderboard

You

—
-

Live pit

+

Entrance room (observer)

diff --git a/examples/game/client/src/generated/mobpit/constants.ts b/examples/game/client/src/generated/mobpit/constants.ts index 0e96cb0a..e01248b1 100644 --- a/examples/game/client/src/generated/mobpit/constants.ts +++ b/examples/game/client/src/generated/mobpit/constants.ts @@ -1,7 +1,7 @@ // @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 } from './events.ts'; +import type { PitRules, PitWorld } from './events.ts'; /** `pit_rules`, as the client decodes it. */ export const PIT_RULES: PitRules = { @@ -15,3 +15,12 @@ export const PIT_RULES: PitRules = { attackHalfAngle: 1.0471975803375244, swingFrames: 6 }; + +/** `pit_world`, as the client decodes it. */ +export const PIT_WORLD: PitWorld = { + defaultSeed: 186474221, + half: 20, + doorHalf: 2.5, + maxRoom: 1000000, + biomes: ['highlands', 'steppe', 'forest', 'meadow'] +}; diff --git a/examples/game/client/src/generated/mobpit/events.ts b/examples/game/client/src/generated/mobpit/events.ts index c748ce26..a3f5878b 100644 --- a/examples/game/client/src/generated/mobpit/events.ts +++ b/examples/game/client/src/generated/mobpit/events.ts @@ -3,124 +3,6 @@ // 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 `BackgroundStats` (`#[derive(GameRecord)]`). */ -export interface BackgroundStats { - readonly enqueued: number; - readonly applied: number; - readonly dropped: number; - readonly failed: number; -} - -const BACKGROUND_STATS_TYPE: WireType = { - type: 'record', - fields: [ - ['enqueued', { type: 'u64' }], - ['applied', { type: 'u64' }], - ['dropped', { type: 'u64' }], - ['failed', { type: 'u64' }], - ] -}; - -/** Record `BoardRow` (`#[derive(GameRecord)]`). */ -export interface BoardRow { - readonly rank: number; - readonly member: string; - readonly name: string; - readonly score: number; -} - -const BOARD_ROW_TYPE: WireType = { - type: 'record', - fields: [ - ['rank', { type: 'u32' }], - ['member', { type: 'short_str' }], - ['name', { type: 'short_str' }], - ['score', { type: 'f64' }], - ] -}; - -/** Record `Scores` (`#[derive(GameRecord)]`). */ -export interface Scores { - readonly kills: number; - readonly xp: number; -} - -const SCORES_TYPE: WireType = { - type: 'record', - fields: [ - ['kills', { type: 'f64' }], - ['xp', { type: 'f64' }], - ] -}; - -/** Record `Mismatch` (`#[derive(GameRecord)]`). */ -export interface Mismatch { - readonly member: string; - readonly cell: Scores; - readonly board: Scores; -} - -const MISMATCH_TYPE: WireType = { - type: 'record', - fields: [ - ['member', { type: 'short_str' }], - ['cell', SCORES_TYPE], - ['board', SCORES_TYPE], - ] -}; - -/** Record `RebuildReport` (`#[derive(GameRecord)]`). */ -export interface RebuildReport { - readonly backend: string; - readonly crawlers: number; - readonly events: number; - readonly applied: number; - readonly duplicates: number; - readonly identicalTop: boolean; - readonly matchesCells: boolean; - readonly mismatches: readonly Mismatch[]; - readonly tookMs: number; -} - -const REBUILD_REPORT_TYPE: WireType = { - type: 'record', - fields: [ - ['backend', { type: 'short_str' }], - ['crawlers', { type: 'u32' }], - ['events', { type: 'u32' }], - ['applied', { type: 'u32' }], - ['duplicates', { type: 'u32' }], - ['identicalTop', { type: 'bool' }], - ['matchesCells', { type: 'bool' }], - ['mismatches', { type: 'list', item: MISMATCH_TYPE }], - ['tookMs', { type: 'f64' }], - ] -}; - -/** Record `BoardSnapshot` (`#[derive(GameRecord)]`). */ -export interface BoardSnapshot { - readonly version: number; - readonly backend: string; - readonly members: number; - readonly kills: readonly BoardRow[]; - readonly xp: readonly BoardRow[]; - readonly background: BackgroundStats; - readonly lastRebuild: RebuildReport | undefined; -} - -const BOARD_SNAPSHOT_TYPE: WireType = { - type: 'record', - fields: [ - ['version', { type: 'u64' }], - ['backend', { type: 'short_str' }], - ['members', { type: 'u64' }], - ['kills', { type: 'list', item: BOARD_ROW_TYPE }], - ['xp', { type: 'list', item: BOARD_ROW_TYPE }], - ['background', BACKGROUND_STATS_TYPE], - ['lastRebuild', { type: 'optional', item: REBUILD_REPORT_TYPE }], - ] -}; - /** Record `MobTier` (`#[derive(GameRecord)]`). */ export interface MobTier { readonly tier: number; @@ -138,6 +20,27 @@ export interface MoveState { readonly cd: number; } +/** Record `PitRoomContext` (`#[derive(GameRecord)]`). */ +export interface PitRoomContext { + readonly seed: number; + readonly x: number; + readonly y: number; +} + +/** Record `RoomMob` (`#[derive(GameRecord)]`). */ +export interface RoomMob { + readonly tier: number; + readonly x: number; + readonly y: number; +} + +/** Record `PitRoomLayout` (`#[derive(GameRecord)]`). */ +export interface PitRoomLayout { + readonly biome: number; + readonly danger: number; + readonly mobs: readonly RoomMob[]; +} + /** Record `PitRules` (`#[derive(GameRecord)]`). */ export interface PitRules { readonly tiers: readonly MobTier[]; @@ -147,6 +50,15 @@ export interface PitRules { readonly swingFrames: number; } +/** Record `PitWorld` (`#[derive(GameRecord)]`). */ +export interface PitWorld { + readonly defaultSeed: number; + readonly half: number; + readonly doorHalf: number; + readonly maxRoom: number; + readonly biomes: readonly string[]; +} + /** Record `Profile` (`#[derive(GameRecord)]`). */ export interface Profile { readonly xp: number; @@ -173,17 +85,25 @@ const PROFILE_TYPE: WireType = { export interface HelloEvent { readonly entity: number; readonly half: number; - readonly boot: string; readonly attackRange: number; readonly attackArcCos: number; + readonly roomX: number; + readonly roomY: number; + readonly seed: number; + readonly restored: boolean; + readonly roomKills: number; } const HELLO_LAYOUT: PayloadLayout = [ ['entity', { type: 'u32' }], ['half', { type: 'f32' }], - ['boot', { type: 'short_str' }], ['attackRange', { type: 'f32' }], ['attackArcCos', { type: 'f32' }], + ['roomX', { type: 'i32' }], + ['roomY', { type: 'i32' }], + ['seed', { type: 'u32' }], + ['restored', { type: 'bool' }], + ['roomKills', { type: 'u32' }], ]; /** Event `profile` (id 2). */ @@ -243,11 +163,6 @@ const SAVED_LAYOUT: PayloadLayout = [ ['attempts', { type: 'u32' }], ]; -/** Event `board` (id 5). */ -export type BoardEvent = BoardSnapshot; - -const BOARD_PAYLOAD: WireType = BOARD_SNAPSHOT_TYPE; - /** Event `died` (id 6). */ export interface DiedEvent { readonly respawnMs: number; @@ -257,13 +172,27 @@ const DIED_LAYOUT: PayloadLayout = [ ['respawnMs', { type: 'u32' }], ]; +/** Message `loaded` (id 1). */ +export interface LoadedMessage { + readonly principal: string; + readonly xp: number; + readonly level: number; + readonly kills: number; +} + +const MESSAGE_LOADED_LAYOUT: PayloadLayout = [ + ['principal', { type: 'short_str' }], + ['xp', { type: 'u64' }], + ['level', { type: 'u32' }], + ['kills', { type: 'u32' }], +]; + /** Event type ids by name. */ export const EVENT = { hello: 1, profile: 2, kill: 3, saved: 4, - board: 5, died: 6, } as const; @@ -273,7 +202,6 @@ export interface Events { profile: ProfileEvent; kill: KillEvent; saved: SavedEvent; - board: BoardEvent; died: DiedEvent; } @@ -303,12 +231,6 @@ export const EVENTS: GameEventCodecs = { encode: (value) => encodePayload(SAVED_LAYOUT, value), decode: (payload) => decodePayload(SAVED_LAYOUT, payload) as unknown as SavedEvent }, - board: { - id: 5, - name: 'board', - encode: (value) => encodeValue(BOARD_PAYLOAD, value), - decode: (payload) => decodeValue(BOARD_PAYLOAD, payload) as BoardEvent - }, died: { id: 6, name: 'died', @@ -333,3 +255,32 @@ export function onEvent( ): () => void { return onGameEvent(client, EVENTS, name, handler); } + +/** Zone inbox message type ids by name. */ +export const MESSAGE = { + loaded: 1, +} as const; + +/** Zone inbox message payload types by name. */ +export interface Messages { + loaded: LoadedMessage; +} + +/** Codecs of every zone inbox message. */ +export const MESSAGES: GameEventCodecs = { + loaded: { + id: 1, + name: 'loaded', + encode: (value) => encodePayload(MESSAGE_LOADED_LAYOUT, value), + decode: (payload) => decodePayload(MESSAGE_LOADED_LAYOUT, payload) as unknown as LoadedMessage + }, +}; + +/** Encode a zone inbox message: `typeId` and `payload` of a `/admin/zone-message` body (base64url the payload). */ +export function encodeMessage( + name: K, + value: Messages[K] +): { readonly typeId: number; readonly payload: Uint8Array } { + const codec = MESSAGES[name]; + return { typeId: codec.id, payload: codec.encode(value) }; +} diff --git a/examples/game/client/src/generated/mobpit/index.ts b/examples/game/client/src/generated/mobpit/index.ts index 481102c6..1bb1759f 100644 --- a/examples/game/client/src/generated/mobpit/index.ts +++ b/examples/game/client/src/generated/mobpit/index.ts @@ -6,4 +6,5 @@ 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/mobpit/pures.ts b/examples/game/client/src/generated/mobpit/pures.ts new file mode 100644 index 00000000..e2d25d5c --- /dev/null +++ b/examples/game/client/src/generated/mobpit/pures.ts @@ -0,0 +1,60 @@ +// @generated by `distributed game-client` from game surface `mobpit` (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 { PitRoomContext, PitRoomLayout } from './events.ts'; + +const PIT_ROOM_CONTEXT_TYPE: WireType = { + type: 'record', + fields: [ + ['seed', { type: 'u32' }], + ['x', { type: 'i32' }], + ['y', { type: 'i32' }], + ] +}; + +const ROOM_MOB_TYPE: WireType = { + type: 'record', + fields: [ + ['tier', { type: 'u8' }], + ['x', { type: 'f32' }], + ['y', { type: 'f32' }], + ] +}; + +const PIT_ROOM_LAYOUT_TYPE: WireType = { + type: 'record', + fields: [ + ['biome', { type: 'u8' }], + ['danger', { type: 'u8' }], + ['mobs', { type: 'list', item: ROOM_MOB_TYPE }], + ] +}; + +/** The pure functions (`#[game_pure]`) of Cargo package `game_sim`: wrapper name → module export. */ +export const PURES = { + pitRoom: { export: 'pit_room', 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/mobpit.wasm', import.meta.url); + +/** Typed calls of the compiled functions (synchronous; they throw until `ready` resolves). */ +export interface MobpitPureFns { + /** `pit_room` (`dg_pure_pit_room`). */ + pitRoom(arg: PitRoomContext): PitRoomLayout; +} + +/** The `mobpit` pure functions plus `ready` / `loaded()` / `module()`. */ +export type MobpitPures = WasmPures; + +/** + * Load the compiled pure functions (default `PURES_WASM_URL`; a URL loaded by + * the predicted step too is instantiated once). + */ +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 }, + }); +} diff --git a/examples/game/client/src/generated/mobpit/schema.ts b/examples/game/client/src/generated/mobpit/schema.ts index 3d9f5f00..8019b0b3 100644 --- a/examples/game/client/src/generated/mobpit/schema.ts +++ b/examples/game/client/src/generated/mobpit/schema.ts @@ -131,18 +131,16 @@ export function pillarFields(entity: { readonly fields: EntityFields }): PillarF export interface PitFields { /** `u16` */ readonly pending: number; + /** `u32` */ + readonly committed: number; /** `u16` */ - readonly slowMs: number; + readonly backlog: number; /** `f32` */ readonly tickMs: number; /** `f32` */ readonly tickMaxMs: number; /** `u32` */ - readonly workUs: number; - /** `u32` */ - readonly committed: number; - /** `u16` */ - readonly backlog: number; + readonly kills: number; } /** True for a `pit` entity. */ @@ -155,12 +153,11 @@ export function pitFields(entity: { readonly fields: EntityFields }): PitFields const f = entity.fields; return { pending: fieldNumber(f, 'pending'), - slowMs: fieldNumber(f, 'slow_ms'), - tickMs: fieldNumber(f, 'tick_ms'), - tickMaxMs: fieldNumber(f, 'tick_max_ms'), - workUs: fieldNumber(f, 'work_us'), committed: fieldNumber(f, 'committed'), backlog: fieldNumber(f, 'backlog'), + tickMs: fieldNumber(f, 'tick_ms'), + tickMaxMs: fieldNumber(f, 'tick_max_ms'), + kills: fieldNumber(f, 'kills'), }; } @@ -196,12 +193,11 @@ export const FIELD_TYPES = { }, pit: { pending: { type: 'u16' }, - slow_ms: { type: 'u16' }, - tick_ms: { type: 'f32' }, - tick_max_ms: { type: 'f32' }, - work_us: { type: 'u32' }, committed: { type: 'u32' }, backlog: { type: 'u16' }, + tick_ms: { type: 'f32' }, + tick_max_ms: { type: 'f32' }, + kills: { type: 'u32' }, }, } as const satisfies Record>; diff --git a/examples/game/client/src/generated/mobpit/surface.json b/examples/game/client/src/generated/mobpit/surface.json index 05ed196c..6c0a39c7 100644 --- a/examples/game/client/src/generated/mobpit/surface.json +++ b/examples/game/client/src/generated/mobpit/surface.json @@ -36,6 +36,25 @@ } ] } + }, + { + "name": "pit_world", + "type": { + "name": "PitWorld", + "type": "record" + }, + "value": { + "biomes": [ + "highlands", + "steppe", + "forest", + "meadow" + ], + "default_seed": 186474221, + "door_half": 2.5, + "half": 20.0, + "max_room": 1000000 + } } ], "events": [ @@ -57,21 +76,45 @@ } }, { - "name": "boot", + "name": "attack_range", "type": { - "type": "short_str" + "type": "f32" } }, { - "name": "attack_range", + "name": "attack_arc_cos", "type": { "type": "f32" } }, { - "name": "attack_arc_cos", + "name": "room_x", "type": { - "type": "f32" + "type": "i32" + } + }, + { + "name": "room_y", + "type": { + "type": "i32" + } + }, + { + "name": "seed", + "type": { + "type": "u32" + } + }, + { + "name": "restored", + "type": { + "type": "bool" + } + }, + { + "name": "room_kills", + "type": { + "type": "u32" } } ], @@ -224,17 +267,6 @@ "kind": "fields" } }, - { - "id": 5, - "name": "board", - "payload": { - "kind": "value", - "type": { - "name": "BoardSnapshot", - "type": "record" - } - } - }, { "id": 6, "name": "died", @@ -542,23 +574,23 @@ }, { "correction": null, - "name": "slow_ms", + "name": "committed", "priority": 1.0, "type": { - "type": "u16" + "type": "u32" } }, { "correction": null, - "name": "tick_ms", + "name": "backlog", "priority": 1.0, "type": { - "type": "f32" + "type": "u16" } }, { "correction": null, - "name": "tick_max_ms", + "name": "tick_ms", "priority": 1.0, "type": { "type": "f32" @@ -566,27 +598,19 @@ }, { "correction": null, - "name": "work_us", + "name": "tick_max_ms", "priority": 1.0, "type": { - "type": "u32" + "type": "f32" } }, { "correction": null, - "name": "committed", + "name": "kills", "priority": 1.0, "type": { "type": "u32" } - }, - { - "correction": null, - "name": "backlog", - "priority": 1.0, - "type": { - "type": "u16" - } } ], "id": 4, @@ -594,6 +618,41 @@ "priority": 1.0 } ], + "messages": [ + { + "id": 1, + "name": "loaded", + "payload": { + "fields": [ + { + "name": "principal", + "type": { + "type": "short_str" + } + }, + { + "name": "xp", + "type": { + "type": "u64" + } + }, + { + "name": "level", + "type": { + "type": "u32" + } + }, + { + "name": "kills", + "type": { + "type": "u32" + } + } + ], + "kind": "fields" + } + } + ], "name": "mobpit", "predict": { "abi": 1, @@ -603,213 +662,135 @@ "state": "MoveState" }, "protocolVersion": 1, - "records": [ + "pures": [ { - "fields": [ - { - "name": "enqueued", - "type": { - "type": "u64" - } - }, - { - "name": "applied", - "type": { - "type": "u64" - } - }, - { - "name": "dropped", - "type": { - "type": "u64" - } - }, - { - "name": "failed", - "type": { - "type": "u64" - } - } - ], - "name": "BackgroundStats" - }, + "abi": 1, + "arg": { + "name": "PitRoomContext", + "type": "record" + }, + "export": "pit_room", + "output": { + "name": "PitRoomLayout", + "type": "record" + }, + "package": "game_sim" + } + ], + "records": [ { "fields": [ { - "name": "rank", - "type": { - "type": "u32" - } - }, - { - "name": "member", + "name": "tier", "type": { - "type": "short_str" + "type": "u8" } }, { - "name": "name", + "name": "radius", "type": { - "type": "short_str" + "type": "f32" } }, { - "name": "score", + "name": "tele", "type": { - "type": "f64" + "type": "f32" } } ], - "name": "BoardRow" + "name": "MobTier" }, { "fields": [ { - "name": "version", - "type": { - "type": "u64" - } - }, - { - "name": "backend", + "name": "x", "type": { - "type": "short_str" + "type": "f32" } }, { - "name": "members", + "name": "y", "type": { - "type": "u64" + "type": "f32" } }, { - "name": "kills", + "name": "vx", "type": { - "item": { - "name": "BoardRow", - "type": "record" - }, - "type": "list" + "type": "f32" } }, { - "name": "xp", + "name": "vy", "type": { - "item": { - "name": "BoardRow", - "type": "record" - }, - "type": "list" + "type": "f32" } }, { - "name": "background", + "name": "dash", "type": { - "name": "BackgroundStats", - "type": "record" + "type": "u8" } }, { - "name": "last_rebuild", + "name": "cd", "type": { - "item": { - "name": "RebuildReport", - "type": "record" - }, - "type": "optional" + "type": "u8" } } ], - "name": "BoardSnapshot" + "name": "MoveState" }, { "fields": [ { - "name": "member", + "name": "seed", "type": { - "type": "short_str" + "type": "u32" } }, { - "name": "cell", + "name": "x", "type": { - "name": "Scores", - "type": "record" + "type": "i32" } }, { - "name": "board", + "name": "y", "type": { - "name": "Scores", - "type": "record" + "type": "i32" } } ], - "name": "Mismatch" + "name": "PitRoomContext" }, { "fields": [ { - "name": "tier", + "name": "biome", "type": { "type": "u8" } }, { - "name": "radius", - "type": { - "type": "f32" - } - }, - { - "name": "tele", - "type": { - "type": "f32" - } - } - ], - "name": "MobTier" - }, - { - "fields": [ - { - "name": "x", - "type": { - "type": "f32" - } - }, - { - "name": "y", - "type": { - "type": "f32" - } - }, - { - "name": "vx", - "type": { - "type": "f32" - } - }, - { - "name": "vy", - "type": { - "type": "f32" - } - }, - { - "name": "dash", + "name": "danger", "type": { "type": "u8" } }, { - "name": "cd", + "name": "mobs", "type": { - "type": "u8" + "item": { + "name": "RoomMob", + "type": "record" + }, + "type": "list" } } ], - "name": "MoveState" + "name": "PitRoomLayout" }, { "fields": [ @@ -856,123 +837,104 @@ { "fields": [ { - "name": "xp", - "type": { - "type": "u64" - } - }, - { - "name": "level", + "name": "default_seed", "type": { "type": "u32" } }, { - "name": "kills", + "name": "half", "type": { - "type": "u32" + "type": "f32" } }, { - "name": "level_xp", + "name": "door_half", "type": { - "type": "u64" + "type": "f32" } }, { - "name": "next_level_xp", + "name": "max_room", "type": { - "type": "u64" + "type": "i32" } }, { - "name": "source", + "name": "biomes", "type": { - "type": "short_str" + "item": { + "type": "short_str" + }, + "type": "list" } } ], - "name": "Profile" + "name": "PitWorld" }, { "fields": [ { - "name": "backend", - "type": { - "type": "short_str" - } - }, - { - "name": "crawlers", - "type": { - "type": "u32" - } - }, - { - "name": "events", + "name": "xp", "type": { - "type": "u32" + "type": "u64" } }, { - "name": "applied", + "name": "level", "type": { "type": "u32" } }, { - "name": "duplicates", + "name": "kills", "type": { "type": "u32" } }, { - "name": "identical_top", - "type": { - "type": "bool" - } - }, - { - "name": "matches_cells", + "name": "level_xp", "type": { - "type": "bool" + "type": "u64" } }, { - "name": "mismatches", + "name": "next_level_xp", "type": { - "item": { - "name": "Mismatch", - "type": "record" - }, - "type": "list" + "type": "u64" } }, { - "name": "took_ms", + "name": "source", "type": { - "type": "f64" + "type": "short_str" } } ], - "name": "RebuildReport" + "name": "Profile" }, { "fields": [ { - "name": "kills", + "name": "tier", + "type": { + "type": "u8" + } + }, + { + "name": "x", "type": { - "type": "f64" + "type": "f32" } }, { - "name": "xp", + "name": "y", "type": { - "type": "f64" + "type": "f32" } } ], - "name": "Scores" + "name": "RoomMob" } ], "schemaVersion": 3 diff --git a/examples/game/client/src/leaderboard/main.ts b/examples/game/client/src/leaderboard/main.ts index 07e28d32..bf8be5d2 100644 --- a/examples/game/client/src/leaderboard/main.ts +++ b/examples/game/client/src/leaderboard/main.ts @@ -1,20 +1,29 @@ /** - * Leaderboard page: observes the pit zone (no avatar), renders the pushed - * top-10 snapshots, reads your rank and neighbours on every push, and - * triggers a rebuild from the cells' event history. + * Leaderboard page: renders the services' top-10 snapshots (a projection of + * the Crawler cells, polled), reads your rank and neighbours on every + * 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, isPillar, isPlayer, mobFields, pillarFields, - playerFields, + playerFields +} from '../generated/mobpit/index.ts'; +import { + createBoardWatcher, + DEFAULT_ZONE, + MobPitApi, + roomUrl, + TIERS, + type BoardPosition, type BoardRow, type BoardSnapshot -} from '../generated/mobpit/index.ts'; -import { createBoardWatcher, DEFAULT_ZONE, MobPitApi, TIERS, type BoardPosition } from '../mobpit/client.ts'; +} 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`). */ @@ -24,8 +33,13 @@ const params = new URLSearchParams(location.search); const zone = params.get('zone') ?? DEFAULT_ZONE; const api = new DevApi(); const pitApi = new MobPitApi(); -const watcher = createBoardWatcher({ api }); -const { client } = watcher; +const watcher = createBoardWatcher(pitApi); +const { client } = createMobpitClient({ + url: (room) => roomUrl(room), + ticket: api.ticketSource(), + pingIntervalMs: 1000, + prediction: false +}); const $ = (k: string) => document.querySelector(`[data-k="${k}"]`) as HTMLElement; const status = $('status'); @@ -74,7 +88,7 @@ async function render(board: BoardSnapshot): Promise { table($('xp'), board.xp, 'XP', me); const bg = board.background; status.textContent = - `push #${pushes} · version ${board.version} · store ${board.backend} · ${board.members} ranked` + + `update #${pushes} · version ${board.version} · store ${board.backend} · ${board.members} ranked` + ` · background updates: ${bg.applied} applied, ${bg.dropped} dropped, ${bg.failed} failed`; status.classList.add('flash'); setTimeout(() => status.classList.remove('flash'), 120); diff --git a/examples/game/client/src/mobpit/client.ts b/examples/game/client/src/mobpit/client.ts index 543ddfed..42613f9d 100644 --- a/examples/game/client/src/mobpit/client.ts +++ b/examples/game/client/src/mobpit/client.ts @@ -1,20 +1,48 @@ /** * Mob Pit networking without rendering (Node-testable): the generated * `createMobpitClient` (the own capsule predicted by `pit_step`, the Rust - * Arena movement compiled to WebAssembly), the zone's typed events folded into a small state object, and - * the `/dev/mobpit/*` routes. + * Arena movement compiled to WebAssembly), the room's typed events folded + * into a small state object, the rooms' generated contents + * (`createMobpitPures().pitRoom`, the Rust room generator as WebAssembly), + * and the `/dev/mobpit/*` routes. + * + * Every room (`pit..`) is a celld Durable Object reached at + * `/rooms/{room}`; celld forwards the socket to whichever node runs it. + * Walking through a doorway, the room hands the player to the next room + * (`Handoff`), and the SDK reconnects there with the transfer ticket. * * 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; `waitedMs` shows the storage latency) → `profile`. + * commit settled) → `profile`. */ -import { GameClient, type GameSocketFactory } from '@hops-ops/distributed/game'; +import type { GameSocketFactory } from '@hops-ops/distributed/game'; import * as pit from '../generated/mobpit/index.ts'; -import { zoneUrl, type DevApi } from '../shared/dev-api.ts'; +import type { DevApi } from '../shared/dev-api.ts'; import { latest } from '../shared/latest.ts'; -export const DEFAULT_ZONE = 'pit-1'; +export const DEFAULT_ZONE = 'pit.0.0'; + +/** `pit..` → `[x, y]` (undefined for anything else). */ +export function parseRoom(zone: string): [number, number] | undefined { + const m = /^pit\.(-?\d+)\.(-?\d+)$/.exec(zone); + return m === null ? undefined : [Number(m[1]), Number(m[2])]; +} + +/** `[x, y]` → `pit..`. */ +export function roomZone(x: number, y: number): string { + return `pit.${x}.${y}`; +} + +/** + * 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 + * proxies `/rooms` to celld). + */ +export function roomUrl(zone: string, base?: string): string { + const origin = base ?? globalThis.location.origin; + return `${origin.replace(/^http/, 'ws')}/rooms/${encodeURIComponent(zone)}`; +} /** `state` names of the `mob` kind, by value (generated from the Rust `MOB_STATES`). */ export const MOB_STATE = pit.PIT_RULES.states; /** Presentation of each tier (client only). */ @@ -31,29 +59,89 @@ export const TIERS: Readonly [t.tier, { ...(TIER_LOOK[t.tier] ?? { name: `Tier ${t.tier}`, color: '#9ad16b' }), radius: t.radius, tele: t.tele }]) ); -/** `/dev/mobpit/leaderboard` neighbourhood (HTTP only, not a zone event). */ -export type BoardPosition = Readonly<{ rank: number | null; score: number | null; around: pit.BoardRow[] }>; +/** 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). */ +export type BoardSnapshot = Readonly<{ + version: number; + backend: string; + members: number; + kills: BoardRow[]; + xp: BoardRow[]; + background: { enqueued: number; applied: number; dropped: number; failed: number }; + lastRebuild: RebuildReport | null; +}>; +export type RebuildReport = Readonly<{ + backend: string; + crawlers: number; + events: number; + applied: number; + duplicates: number; + identicalTop: boolean; + matchesCells: boolean; + mismatches: { member: string; cell: { kills: number; xp: number }; board: { kills: number; xp: number } }[]; + tookMs: number; +}>; +/** `/dev/mobpit/leaderboard` neighbourhood. */ +export type BoardPosition = Readonly<{ rank: number | null; score: number | null; around: BoardRow[] }>; + +/** A room as the `zone_status` read model knows it (`/dev/mobpit/rooms`). */ +export type RoomStatus = Readonly<{ + zone: string; + x: number; + y: number; + /** `active` · `dormant` · `hibernated` (unknown rooms are cold). */ + status: 'active' | 'dormant' | 'hibernated'; + players: number; + /** The current activation restored a snapshot. */ + restored: boolean; + activations: number; + /** celld node of the activation — a debug detail (empty when unknown). */ + node: string; + changedAtMs: number; +}>; + +/** A room object's debug JSON (`/rooms/{room}/info`). */ +export type RoomInfo = Readonly<{ + zone: string; + status: string; + awake: boolean; + activation: string; + restored: boolean; + wakes: number; + node: string; + isolate: string; + players: number; + tick: number; + hz: number; + lateMs: { p50: number; p99: number }; + droppedTicks: number; +}>; export type MobPitClient = pit.MobpitClient & Readonly<{ hello(): pit.HelloEvent | undefined; profile(): pit.Profile | undefined; - board(): pit.BoardSnapshot | undefined; + /** The room the player is in (authority zone). */ + room(): string | undefined; /** Kills by me whose fact has not settled yet (key → event). */ unsaved: ReadonlyMap; + /** The rooms' generated contents (the Rust generator as WASM). */ + pures: ReturnType; /** Resolves on the first hello, once the predicted step is loaded. */ ready: Promise; }>; export type MobPitClientOptions = Readonly<{ api: DevApi; - base?: string; + /** http(s) origin of the rooms (celld); default the page's origin. */ + roomsBase?: string; socket?: GameSocketFactory; }>; export function createMobPitClient(options: MobPitClientOptions): MobPitClient { const typed = pit.createMobpitClient({ - url: (zone) => zoneUrl(zone, options.base), + url: (zone) => roomUrl(zone, options.roomsBase), ticket: options.api.ticketSource(), socket: options.socket, pingIntervalMs: 500, @@ -65,54 +153,85 @@ export function createMobPitClient(options: MobPitClientOptions): MobPitClient { (value) => typed.client.setControlledEntity(value.entity) ); const profile = latest((h) => typed.on('profile', h)); - const board = latest((h) => typed.on('board', h)); const unsaved = new Map(); typed.on('kill', (kill) => { if (kill.killer === hello.get()?.entity) unsaved.set(kill.key, kill); }); typed.on('saved', (saved) => unsaved.delete(saved.key)); + const pures = pit.createMobpitPures(); return { ...typed, hello: hello.get, profile: profile.get, - board: board.get, + room: () => typed.client.authority?.zoneId, unsaved, - ready: Promise.all([hello.ready, typed.prediction?.ready]).then(([h]) => h) + pures, + ready: Promise.all([hello.ready, typed.prediction?.ready, pures.ready]).then(([h]) => h) }; } -/** Observer-only client for the leaderboard page (no prediction, no inputs). */ -export function createBoardWatcher(options: MobPitClientOptions): Readonly<{ - client: GameClient; - board(): pit.BoardSnapshot | undefined; - on(handler: (value: pit.BoardSnapshot) => void): () => void; +/** Polls the services' leaderboard snapshot (the board is a projection of the cells). */ +export function createBoardWatcher(pitApi: MobPitApi, everyMs = 500): Readonly<{ + board(): BoardSnapshot | undefined; + on(handler: (value: BoardSnapshot) => void): () => void; + stop(): void; }> { - const typed = pit.createMobpitClient({ - url: (zone) => zoneUrl(zone, options.base), - ticket: options.api.ticketSource(), - socket: options.socket, - pingIntervalMs: 1000, - prediction: false - }); - const board = latest((h) => typed.on('board', h)); - return { client: typed.client, board: board.get, on: (handler) => typed.on('board', handler) }; + let current: BoardSnapshot | undefined; + const handlers = new Set<(value: BoardSnapshot) => void>(); + let stopped = false; + const poll = async () => { + while (!stopped) { + try { + const { snapshot } = await pitApi.leaderboard(); + if (current === undefined || snapshot.version !== current.version) { + current = snapshot; + for (const h of handlers) h(snapshot); + } + } catch { + // Services restarting: try again. + } + await new Promise((r) => setTimeout(r, everyMs)); + } + }; + void poll(); + return { + board: () => current, + on: (handler) => { + handlers.add(handler); + return () => handlers.delete(handler); + }, + stop: () => { + stopped = true; + } + }; } /** `/dev/mobpit/*` (dev only; none of these take an identity or a reward). */ export class MobPitApi { readonly base: string; + readonly roomsBase: string; - constructor(base = '') { + /** `base`: the services' origin; `roomsBase`: celld's (both default to the page's). */ + constructor(base = '', roomsBase = base) { this.base = base; + this.roomsBase = roomsBase; + } + + /** Every room the zone status read model knows. */ + rooms(): Promise<{ rooms: RoomStatus[] }> { + return this.#json('/dev/mobpit/rooms'); } - storage(): Promise<{ latencyMs: number }> { - return this.#json('/dev/mobpit/storage'); + /** A room object's debug JSON, straight from the room on celld (does not wake it). */ + async roomInfo(zone: string): Promise { + const response = await fetch(`${this.roomsBase}/rooms/${encodeURIComponent(zone)}/info`); + if (!response.ok) throw new Error(`room info ${zone}: HTTP ${response.status}`); + return (await response.json()) as RoomInfo; } - /** Artificial latency in front of every durable commit (server-wide). */ - setStorage(latencyMs: number): Promise<{ latencyMs: number }> { - return this.#json('/dev/mobpit/storage', { latencyMs }); + /** The player's presence cell. */ + presence(principal: string): Promise<{ presence: { zone: string; status: string } | null }> { + return this.#json(`/dev/mobpit/presence?principal=${encodeURIComponent(principal)}`); } stats(): Promise> { @@ -123,12 +242,12 @@ export class MobPitApi { return this.#json('/dev/mobpit/crawlers'); } - leaderboard(member?: string): Promise<{ snapshot: pit.BoardSnapshot; me: { kills: BoardPosition; xp: BoardPosition } | null }> { + leaderboard(member?: string): Promise<{ snapshot: BoardSnapshot; me: { kills: BoardPosition; xp: BoardPosition } | null }> { const query = member === undefined ? '' : `?member=${encodeURIComponent(member)}`; return this.#json(`/dev/mobpit/leaderboard${query}`); } - rebuild(): Promise { + rebuild(): Promise { return this.#json('/dev/mobpit/leaderboard/rebuild', {}); } diff --git a/examples/game/client/src/mobpit/main.ts b/examples/game/client/src/mobpit/main.ts index 661a50e9..6cfd0624 100644 --- a/examples/game/client/src/mobpit/main.ts +++ b/examples/game/client/src/mobpit/main.ts @@ -1,12 +1,15 @@ /** * Mob Pit page: Babylon scene over `createMobPitClient`. * - * What to look at: kill a mob (Space) and watch the grey "saving…" popup - * turn into "+XP saved ✓" when the durable cell commit settles. Toggle - * *Slow storage* (L): every commit now takes 1.5 s — the pending-facts graph - * climbs while the tick graph stays flat at ~33 ms, because the zone never - * waits for storage. Kill the server (`kill -9`), restart it, reload: your - * XP and kills come back from the cell. + * What to look at: rooms are celld Durable Objects on a near-infinite + * seeded grid. Walk through a doorway (the gaps in the middle of each wall) + * into the next room; the minimap (bottom right) shows every known room + * live — active, dormant (just left), hibernated (asleep in storage) — from + * the `zone_status` read model. Come back to a room after it slept: it + * wakes from its snapshot with the mobs you left (toast). Kill a mob + * (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. */ import type { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial'; import { Color3 } from '@babylonjs/core/Maths/math.color'; @@ -18,6 +21,7 @@ import { ARENA_FLOOR } from '../generated/arena/constants.ts'; import { BUTTON, PIT_RULES, + PIT_WORLD, isMob, isPillar, isPit, @@ -25,7 +29,6 @@ import { pillarFields, pitFields, playerFields, - type BoardSnapshot, type PitFields, type PitInput } from '../generated/mobpit/index.ts'; @@ -33,7 +36,18 @@ 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 { createMobPitClient, DEFAULT_ZONE, MobPitApi, TIERS } from './client.ts'; +import { + createBoardWatcher, + createMobPitClient, + DEFAULT_ZONE, + MobPitApi, + parseRoom, + roomZone, + TIERS, + type BoardSnapshot, + type RoomInfo, + type RoomStatus +} from './client.ts'; /** The Arena floor, generated from the simulation's constants (`game_sim::arena::arena_floor`). */ const { half: HALF, playerRadius: PLAYER_RADIUS } = ARENA_FLOOR; @@ -41,7 +55,6 @@ const { half: HALF, playerRadius: PLAYER_RADIUS } = ARENA_FLOOR; const { attackRange, attackHalfAngle: SWING_HALF, swingFrames: SWING_FRAMES } = PIT_RULES; const ATTACK_REACH = attackRange + PLAYER_RADIUS; -const SLOW_MS = 1500; const params = new URLSearchParams(location.search); const zone = params.get('zone') ?? DEFAULT_ZONE; const storedName = (() => { @@ -64,19 +77,12 @@ const { scene } = stage; const hud = new Hud({ title: 'Mob Pit', - hint: 'WASD move · Space attack · E dash · L slow storage. Kills are durable facts; the tick never waits for them.', + 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.', client, devApi: api }); -let slow = false; -hud.toggle(`Slow storage (${SLOW_MS} ms per commit)`, false, (on) => { - slow = on; - pitApi.setStorage(on ? SLOW_MS : 0).catch((e: unknown) => hud.row('Storage', `failed: ${String(e)}`)); -}, 'l'); -pitApi - .storage() - .then((s) => (slow = s.latencyMs > 0)) - .catch(() => undefined); +let debug = params.get('debug') !== '0'; +hud.toggle('Debug overlay (celld node per room)', debug, (on) => (debug = on), 'o'); // ---- side panels: XP bar, durable facts graph, mini leaderboard ---- const xpPanel = div('pit-xp', document.body); @@ -103,6 +109,109 @@ function div(className: string, parent: HTMLElement): HTMLDivElement { return d; } +// ---- 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
+
`; +const roomsMap = roomsPanel.querySelector('canvas') as HTMLCanvasElement; +const roomsField = (k: string) => roomsPanel.querySelector(`[data-k="${k}"]`) as HTMLElement; +const toasts = div('pit-toasts', document.body); +const rooms = new Map(); +let roomInfo: RoomInfo | undefined; +const STATUS_COLOR: Record = { active: '#4caf50', dormant: '#f2c94c', hibernated: '#6c7aa0' }; +const BIOME_COLOR = ['#8a8f99', '#c9a86a', '#3f7d4e', '#7fbf6a']; + +function toast(text: string): void { + const t = div('pit-toast', toasts); + t.textContent = text; + setTimeout(() => t.remove(), 5000); +} + +async function pollRooms(): Promise { + for (;;) { + try { + const { rooms: rows } = await pitApi.rooms(); + for (const row of rows) { + const before = rooms.get(row.zone); + if (before !== undefined && before.status !== row.status) { + if (row.status === 'active' && row.restored && row.activations > before.activations) { + toast(`${row.zone} woke up from hibernation — its mobs are back`); + } else if (row.status === 'hibernated') { + toast(`${row.zone} went to sleep (snapshot in celld storage)`); + } + } + rooms.set(row.zone, row); + } + } catch { + // The services restarting: keep the last view. + } + await new Promise((r) => setTimeout(r, 500)); + } +} +void pollRooms(); + +async function pollRoomInfo(): Promise { + for (;;) { + const here = pit.room(); + if (debug && here !== undefined) roomInfo = await pitApi.roomInfo(here).catch(() => roomInfo); + await new Promise((r) => setTimeout(r, 1000)); + } +} +void pollRoomInfo(); + +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 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 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'; + ctx.lineWidth = 2; + ctx.strokeRect(i * cell + 1, j * cell + 1, cell - 2, cell - 2); + } + 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); + } + } +} + +function renderDebug(): void { + const el = roomsField('debug'); + roomsField('here').textContent = pit.room() ?? ''; + if (!debug) { + el.textContent = ''; + return; + } + const info = roomInfo; + if (info === undefined || info.zone !== pit.room()) { + el.textContent = 'room object: …'; + return; + } + const node = info.node === '' ? 'unknown (celld does not tell code its node; set CELLD_ZONE_NODES)' : info.node; + el.innerHTML = `celld object ${info.zone}
node ${node}
isolate ${info.isolate} · activation ${info.activation}` + + `
wakes ${info.wakes} · ${info.restored ? 'restored from snapshot' : 'fresh'} · ${info.players} players` + + `
tick ${info.hz} Hz · late p50 ${info.lateMs.p50.toFixed(1)} / p99 ${info.lateMs.p99.toFixed(1)} ms`; +} + // ---- materials and templates ---- const mats = { me: material(scene, 'me', '#6cc4ff'), @@ -121,6 +230,49 @@ 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)); +// Doorways: a glowing gate in the middle of each wall, labelled with the +// room behind it; the floor takes the room's biome colour. +const doorMat = material(scene, 'door', '#4dd0e1', 0.55); +doorMat.emissiveColor = Color3.FromHexString('#4dd0e1'); +const DOORS: { dx: number; dy: number }[] = [ + { dx: 1, dy: 0 }, + { dx: -1, dy: 0 }, + { dx: 0, dy: 1 }, + { dx: 0, dy: -1 } +]; +const doorMeshes = DOORS.map(({ dx, dy }, i) => { + const gate = MeshBuilder.CreateBox( + `door-${i}`, + { width: dx === 0 ? PIT_WORLD.doorHalf * 2 : 0.3, depth: dy === 0 ? PIT_WORLD.doorHalf * 2 : 0.3, height: 0.2 }, + scene + ); + gate.material = doorMat; + gate.isPickable = false; + gate.position.copyFrom(toWorld(dx * HALF, dy * HALF, 0.1)); + return gate; +}); +const biomeFloor = MeshBuilder.CreateGround('biome', { width: HALF * 2, height: HALF * 2 }, scene); +biomeFloor.position.y = 0.01; +biomeFloor.isPickable = false; +const biomeMat = material(scene, 'biome', '#3f7d4e', 0.18); +biomeFloor.material = biomeMat; +let shownRoom = ''; + +function showRoom(zone: string): void { + const at = parseRoom(zone); + if (at === undefined || zone === shownRoom) return; + shownRoom = zone; + DOORS.forEach(({ dx, dy }, i) => { + stage.labels.set(`door-${i}`, doorMeshes[i]!, `→ ${roomZone(at[0] + dx, at[1] + dy)}`, 0.6, 'door'); + }); + const seed = pit.hello()?.seed ?? PIT_WORLD.defaultSeed; + if (pit.pures.loaded()) { + const room = pit.pures.pitRoom({ seed, x: at[0], y: at[1] }); + biomeMat.diffuseColor = Color3.FromHexString(BIOME_COLOR[room.biome] ?? '#3f7d4e'); + hud.row('Room', `${zone} · ${PIT_WORLD.biomes[room.biome] ?? '?'} · danger ${room.danger} · ${room.mobs.length} mobs`); + } +} + 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 PillarVisual = { kind: 'pillar'; body: Mesh }; @@ -226,7 +378,11 @@ pit.on('died', (d) => { deathBanner.style.display = ''; setTimeout(() => (deathBanner.style.display = 'none'), d.respawnMs); }); -pit.on('board', (board) => renderBoard(board)); +pit.on('hello', (hello) => { + if (hello.restored) toast(`You entered ${roomZone(hello.roomX, hello.roomY)}: restored from hibernation (${hello.roomKills} kills here so far)`); +}); +const boards = createBoardWatcher(pitApi, 1000); +boards.on((board) => renderBoard(board)); function renderBoard(board: BoardSnapshot): void { const me = api.last?.principal; @@ -380,15 +536,13 @@ stage.onFrame((now, dtMs) => { const pending = stats?.pending ?? 0; const tickMs = stats?.tickMs ?? 0; const tickMax = stats?.tickMaxMs ?? 0; - const slowMs = stats?.slowMs ?? 0; hud.row('You', api.last === undefined ? '—' : `${api.last.name} · ${api.last.principal}`); hud.row('HP', me === undefined ? '—' : me.dead ? 'dead' : String(me.hp)); hud.row('Facts pending (zone)', String(pending)); hud.row('Facts saved (zone)', String(stats?.committed ?? 0)); hud.row('My unsaved kills', String(pit.unsaved.size)); - hud.row('Storage latency', slowMs > 0 ? `${slowMs} ms (slow)` : 'normal'); - hud.row('Server tick interval', `${tickMs.toFixed(1)} ms avg · ${tickMax.toFixed(1)} max`); - hud.row('Tick work', `${stats?.workUs ?? 0} µs`); + 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 profile = pit.profile(); if (profile !== undefined) { const span = Math.max(1, profile.nextLevelXp - profile.levelXp); @@ -399,10 +553,12 @@ stage.onFrame((now, dtMs) => { } field('pending').textContent = String(pending); field('saved').textContent = String(savedCount); - field('note').textContent = slow || slowMs > 0 - ? `Storage is slow: each commit waits ${slowMs} ms. Pending climbs, the tick does not.` - : 'Storage is fast: facts settle within a tick or two.'; + field('note').textContent = 'Kills settle in your Crawler cell next to the room on celld; the tick never waits for them.'; + const here = pit.room(); + if (here !== undefined) showRoom(here); if (now - lastSampleAt > 250) { + drawRooms(); + renderDebug(); lastSampleAt = now; samples.push({ pending, tick: tickMs }); if (samples.length > 120) samples.shift(); @@ -415,6 +571,6 @@ declare global { mobpit?: unknown; } } -window.mobpit = { client, api, pitApi, pit, scene }; +window.mobpit = { client, api, pitApi, pit, scene, rooms, boards }; client.connect(zone).catch((error: unknown) => hud.row('Status', `failed: ${String(error)}`)); diff --git a/examples/game/client/src/mobpit/style.css b/examples/game/client/src/mobpit/style.css index 31836839..bdec1b33 100644 --- a/examples/game/client/src/mobpit/style.css +++ b/examples/game/client/src/mobpit/style.css @@ -118,3 +118,61 @@ width: 220px; } } +/* Rooms minimap (zone status read model) and the debug overlay. */ +.pit-rooms { + position: fixed; + right: 12px; + bottom: 16px; + width: 234px; + padding: 10px 12px; + border-radius: 8px; + background: var(--panel); + box-sizing: border-box; +} +.pit-rooms h2 { + margin: 0 0 6px; + font-size: 13px; +} +.pit-rooms h2 small { + color: var(--muted); + font-weight: normal; +} +.pit-rooms .c-active { + color: #4caf50; +} +.pit-rooms .c-dormant { + color: #f2c94c; +} +.pit-rooms .c-hibernated { + color: #6c7aa0; +} +.pit-rooms .c-cold { + color: #556070; +} +.pit-debug { + margin-top: 6px; + font: 11px/1.4 monospace; + color: var(--muted); + word-break: break-word; +} +.pit-toasts { + position: fixed; + top: 12px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 6px; + pointer-events: none; + z-index: 10; +} +.pit-toast { + padding: 8px 14px; + border-radius: 6px; + background: #1f3a4d; + color: #e8f4ff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); +} +.label.door { + color: #4dd0e1; +} diff --git a/examples/game/client/tests/helpers/celld.mjs b/examples/game/client/tests/helpers/celld.mjs index e4de7bfc..76ab13f0 100644 --- a/examples/game/client/tests/helpers/celld.mjs +++ b/examples/game/client/tests/helpers/celld.mjs @@ -34,9 +34,13 @@ export function freePort() { /** * Start celld. `relayPort`: the demo server port its outbox Queue relays - * domain events to (`/internal/celld-queue/relay`). + * domain events to (`/internal/celld-queue/relay`); `vars`: extra worker + * vars (e.g. `ZONE_HIBERNATE_AFTER_MS` for Mob Pit's rooms). The rooms' + * ticket secret (`GAME_TICKET_SECRET`) is generated here and returned as + * `ticketSecret` (and in `env` as `DISTRIBUTED_GAME_SECRET`, what + * `game-demos mobpit` signs room tickets with). */ -export async function startCelld({ relayPort } = {}) { +export async function startCelld({ relayPort, vars: extra = {} } = {}) { const bin = process.env.CELLD_BIN; if (!bin) throw new Error('CELLD_BIN is not set: the demos\' cells run on celld (the version in .celld-version); point CELLD_BIN at the celld binary'); if (!existsSync(join(CELLS, 'build/worker/shim.mjs'))) { @@ -46,8 +50,10 @@ export async function startCelld({ relayPort } = {}) { cpSync(join(CELLS, 'build'), join(project, 'build'), { recursive: true }); cpSync(join(CELLS, 'wrangler.jsonc'), join(project, 'wrangler.jsonc')); const secret = `test-only-${randomBytes(24).toString('hex')}`; - let vars = `DISTRIBUTED_INTERNAL_SECRET=${secret}\n`; + const ticketSecret = randomBytes(32).toString('hex'); + let vars = `DISTRIBUTED_INTERNAL_SECRET=${secret}\nGAME_TICKET_SECRET=${ticketSecret}\n`; if (relayPort) vars += `CELLD_QUEUE_RELAY_URL=http://127.0.0.1:${relayPort}${RELAY_PATH}\n`; + for (const [key, value] of Object.entries(extra)) vars += `${key}=${value}\n`; writeFileSync(join(project, '.dev.vars'), vars); const port = await freePort(); const child = spawn(bin, ['dev', project, '--host', '127.0.0.1', '--port', String(port), '--no-watch', '--logs'], { @@ -72,9 +78,10 @@ export async function startCelld({ relayPort } = {}) { return { url, secret, + ticketSecret, project, /** Env for `game-demos mobpit`. */ - env: { CELLD_URL: url, DISTRIBUTED_INTERNAL_SECRET: secret }, + env: { CELLD_URL: url, DISTRIBUTED_INTERNAL_SECRET: secret, DISTRIBUTED_GAME_SECRET: ticketSecret }, output: () => output, async stop() { if (child.exitCode !== null || child.signalCode !== null) return; diff --git a/examples/game/client/tests/helpers/mobpit.mjs b/examples/game/client/tests/helpers/mobpit.mjs index 0dec97c6..6acac3a9 100644 --- a/examples/game/client/tests/helpers/mobpit.mjs +++ b/examples/game/client/tests/helpers/mobpit.mjs @@ -1,7 +1,8 @@ /** - * Headless Mob Pit helpers: a server on its own `celld dev` (the Crawler - * and presence cells), a throwaway data dir and a scripted hunter - * that walks to the nearest mob, faces it and swings until `done()` is true. + * Headless Mob Pit helpers: the rooms and cells on their own `celld dev`, + * the services (`game-demos mobpit`) on a throwaway data dir, and a + * scripted hunter that walks to the nearest mob, faces it and swings until + * `done()` is true. */ import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -16,13 +17,14 @@ export function tempDataDir() { } /** - * celld + `game-demos ` (mobpit / leaderboard) on a fixed port that - * celld's outbox relay targets. `restart()` restarts only the game server - * (same port, data dir and cells); `stop()` stops both. + * celld (rooms + cells) + `game-demos ` (mobpit / leaderboard: the + * services) on a fixed port that celld's outbox relay targets. + * `roomsUrl` is where rooms are (celld). `restart()` restarts only the + * services (same port, data dir and cells); `stop()` stops both. */ -export async function startPit(demo = 'mobpit') { +export async function startPit(demo = 'mobpit', vars = {}) { const port = await freePort(); - const celld = await startCelld({ relayPort: port }); + const celld = await startCelld({ relayPort: port, vars: { ZONE_HIBERNATE_AFTER_MS: '1500', ...vars } }); const env = { ...celld.env, MOBPIT_DATA_DIR: tempDataDir() }; let server; try { @@ -33,6 +35,7 @@ export async function startPit(demo = 'mobpit') { } return { celld, + roomsUrl: celld.url, get httpUrl() { return server.httpUrl; }, @@ -47,6 +50,33 @@ export async function startPit(demo = 'mobpit') { }; } +/** Walk to the doorway on side `[dx, dy]` and push through it until the + * client's authority is another room (one command frame per tick). */ +export async function walkThroughDoor(pit, [dx, dy], timeoutMs = 30_000) { + const { client } = pit; + const from = pit.room(); + const deadline = Date.now() + timeoutMs; + while (pit.room() === from) { + if (Date.now() > deadline) throw new Error(`walk through door ${dx},${dy} timed out`); + const rendered = client.render(); + const meEntity = rendered.find((e) => e.id === pit.hello()?.entity && isPlayer(e) && e.zoneId === from); + const me = meEntity && playerFields(meEntity); + let input = { mx: 0, my: 0, buttons: 0 }; + if (me !== undefined) { + // Beside the pillar on the door's axis, then straight out. + const tx = dx !== 0 ? dx * 21 : 2; + const ty = dy !== 0 ? dy * 21 : 2; + const ddx = tx - me.x; + const ddy = ty - me.y; + const len = Math.max(Math.hypot(ddx, ddy), 1e-3); + input = { mx: Math.round((ddx / len) * 127), my: Math.round((ddy / len) * 127), buttons: 0 }; + } + client.sendInput(input); + await sleep(1000 / 30); + } + return pit.room(); +} + /** Hunt mobs (one command frame per 30 Hz tick) until `done()` or timeout. */ export async function hunt(pit, done, timeoutMs = 45_000) { const { client } = pit; @@ -54,9 +84,10 @@ export async function hunt(pit, done, timeoutMs = 45_000) { while (!done()) { if (Date.now() > deadline) throw new Error('hunt timed out'); const rendered = client.render(); - const meEntity = rendered.find((e) => e.id === pit.hello()?.entity && isPlayer(e)); + const room = pit.room?.(); + const meEntity = rendered.find((e) => e.id === pit.hello()?.entity && isPlayer(e) && (room === undefined || e.zoneId === room)); const me = meEntity && playerFields(meEntity); - const mobs = rendered.filter((e) => isMob(e) && !e.stale).map(mobFields); + const mobs = rendered.filter((e) => isMob(e) && !e.stale && (room === undefined || e.zoneId === room)).map(mobFields); let input = { mx: 0, my: 0, buttons: 0 }; if (me !== undefined && mobs.length > 0 && !me.dead) { const target = mobs diff --git a/examples/game/client/tests/leaderboard-headless.test.mjs b/examples/game/client/tests/leaderboard-headless.test.mjs index 4a72e22e..379ea2a4 100644 --- a/examples/game/client/tests/leaderboard-headless.test.mjs +++ b/examples/game/client/tests/leaderboard-headless.test.mjs @@ -1,7 +1,8 @@ /** - * Headless Leaderboard: a hunter scores kills in the pit, an observer - * receives pushed board snapshots whose scores equal the celld Crawler cells; - * rebuilding from the cells' events yields the identical board. + * Headless Leaderboard: a hunter scores kills in a room on celld; the + * services' board (a projection of the Crawler cells) follows, its scores + * equal the cells; rebuilding from the cells' events yields the identical + * board. */ import assert from 'node:assert/strict'; import test from 'node:test'; @@ -14,31 +15,29 @@ import { hunt, startPit } from './helpers/mobpit.mjs'; test('board pushes match the cells and rebuild is identical', { timeout: 600_000 }, async () => { const server = await startPit('leaderboard'); const clients = []; + let watcher; try { const storage = memoryStorage(); const api = new DevApi({ base: server.httpUrl, name: () => 'Ranker', storage }); - const pitApi = new MobPitApi(server.httpUrl); + const pitApi = new MobPitApi(server.httpUrl, server.roomsUrl); - // Observer page first: it gets the current (empty) snapshot on observe. - const watcher = createBoardWatcher({ api: new DevApi({ base: server.httpUrl, storage }), base: server.httpUrl }); - clients.push(watcher.client); + watcher = createBoardWatcher(pitApi, 200); const pushes = []; watcher.on((b) => pushes.push(b)); - await watcher.client.observe('pit-1'); - await until(() => pushes.length >= 1, 5_000, 'initial board push'); + await until(() => pushes.length >= 1, 5_000, 'initial board'); assert.equal(watcher.board().backend, 'memory'); - const pit = createMobPitClient({ api, base: server.httpUrl }); + const pit = createMobPitClient({ api, roomsBase: server.roomsUrl }); clients.push(pit.client); - await pit.client.connect('pit-1'); + await pit.client.connect('pit.0.0'); await pit.ready; const me = api.last.principal; await hunt(pit, () => (pit.profile()?.kills ?? 0) >= 2); const profile = pit.profile(); - // Pushed, not polled: the observer sees my kills without asking. + // The board follows the cells (via the projected crawler list). const row = (b, board) => b[board].find((r) => r.member === me); - await until(() => row(watcher.board(), 'kills')?.score === profile.kills, 10_000, 'pushed kills'); + await until(() => row(watcher.board(), 'kills')?.score === profile.kills, 20_000, 'board kills'); assert.equal(row(watcher.board(), 'kills').name, 'Ranker'); assert.equal(row(watcher.board(), 'xp').score, profile.xp); // The crawler list (and so the rebuild) is a projection of the cells' @@ -61,8 +60,9 @@ test('board pushes match the cells and rebuild is identical', { timeout: 600_000 assert.equal(report.identicalTop, true); const after = (await pitApi.leaderboard()).snapshot; assert.deepEqual([after.kills, after.xp], top, 'rebuild from events = live board'); - await until(() => watcher.board().lastRebuild?.events === profile.kills, 5_000, 'rebuild pushed'); + await until(() => watcher.board().lastRebuild?.events === profile.kills, 5_000, 'rebuild shown'); } finally { + watcher?.stop(); for (const c of clients) c.close(); await server.stop(); } diff --git a/examples/game/client/tests/mobpit-headless.test.mjs b/examples/game/client/tests/mobpit-headless.test.mjs index 2bb03875..711ac2eb 100644 --- a/examples/game/client/tests/mobpit-headless.test.mjs +++ b/examples/game/client/tests/mobpit-headless.test.mjs @@ -1,99 +1,103 @@ /** - * Headless Mob Pit (Node, real `game-demos mobpit` binary on its own - * `celld dev`): join → profile read from the celld Crawler cell → kill a mob - * → the fact settles ("saved") with server-decided XP → slow storage makes - * the settlement wait while the tick stays flat → restart the server → XP is - * still there. + * Headless Mob Pit (Node): the rooms on their own `celld dev` (each room a + * Durable Object), the services binary (`game-demos mobpit`), a scripted + * player: + * + * join the entrance room → profile read from the celld Crawler cell → kill + * a mob → the fact settles ("saved") with server-decided XP → walk through + * a doorway into the next room → the left room goes dormant, then + * hibernated (zone status read model) → walk back → the room wakes from its + * snapshot with the mobs killed there still dead → restart the services: + * the profile is still there. */ import assert from 'node:assert/strict'; import test from 'node:test'; -import { isPit, pitFields } from '../src/generated/mobpit/index.ts'; +import { isMob, isPit, pitFields } 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'; +import { hunt, startPit, walkThroughDoor } from './helpers/mobpit.mjs'; const XP = new Set([10, 25, 60]); -test('kills become durable facts without stalling the tick, and survive a restart', { timeout: 600_000 }, async () => { +test('rooms on celld: kills settle, doorways hand over, left rooms sleep and restore', { timeout: 600_000 }, async () => { const storage = memoryStorage(); const server = await startPit('mobpit'); const clients = []; try { const api = new DevApi({ base: server.httpUrl, name: () => 'Hunter', storage }); - const pitApi = new MobPitApi(server.httpUrl); - const pit = createMobPitClient({ api, base: server.httpUrl }); + const pitApi = new MobPitApi(server.httpUrl, server.roomsUrl); + const pit = createMobPitClient({ api, roomsBase: server.roomsUrl }); clients.push(pit.client); - const welcome = await pit.client.connect('pit-1'); + const welcome = await pit.client.connect('pit.0.0'); assert.deepEqual(welcome.schema.kinds.map((k) => k.name), ['player', 'mob', 'pillar', 'pit']); - await pit.ready; - await until(() => pit.profile() !== undefined, 5_000, 'profile from the cell'); + const hello = await pit.ready; + assert.deepEqual([hello.roomX, hello.roomY, hello.restored], [0, 0, false]); + await until(() => pit.profile() !== undefined, 10_000, 'profile from the cell'); assert.equal(pit.profile().source, 'cell'); assert.equal(pit.profile().kills, 0); + // The room's own seeded contents, computed by the same Rust as WASM. + const layout = pit.pures.pitRoom({ seed: hello.seed, x: 0, y: 0 }); + const mobsHere = () => pit.client.render().filter((e) => isMob(e) && e.zoneId === 'pit.0.0' && !e.stale).length; + await until(() => mobsHere() === layout.mobs.length, 5_000, 'mobs of the generated layout'); - // First kill at normal speed. + // A kill settles in the Crawler cell. const saved = []; const kills = []; pit.on('saved', (s) => saved.push(s)); pit.on('kill', (k) => k.killer === pit.hello().entity && kills.push(k)); await hunt(pit, () => saved.length >= 1); assert.ok(saved[0].ok && XP.has(saved[0].xp), JSON.stringify(saved[0])); - assert.equal(saved[0].key, kills[0].key); - assert.match( - saved[0].key, - /^crawler\.record_kill:pit-1:\d+:m\d+:\d+$/, - '{command}:{zone}:{boot_epoch}:m{mob}:{generation}' - ); - // One swing can kill several mobs: wait until every own kill settled. - await until(() => pit.unsaved.size === 0 && saved.length === kills.length, 5_000, 'settlements'); - await until(() => pit.profile().source === 'settled' && pit.profile().kills === kills.length, 5_000, `profile after settlement ${JSON.stringify(pit.profile())}`); - - // Slow storage: the next kill stays unsaved for ~1.5 s, the tick does not slow down. - assert.equal((await pitApi.setStorage(1500)).latencyMs, 1500); + assert.match(saved[0].key, /^crawler\.record_kill:pit\.0\.0:\d+:m\d+:\d+$/, '{command}:{room}:{boot_epoch}:m{mob}:{generation}'); + await until(() => pit.unsaved.size === 0 && saved.length === kills.length, 10_000, 'settlements'); const pitStats = () => { - const entity = pit.client.render().find(isPit); + const entity = pit.client.render().find((e) => isPit(e) && e.zoneId === pit.room()); return entity && pitFields(entity); }; - const fastKills = kills.length; - await hunt(pit, () => kills.length > fastKills); - await until(() => (pitStats()?.pending ?? 0) >= 1, 2_000, 'pending fact replicated'); - assert.ok(pit.unsaved.size >= 1, 'kill not yet saved'); - const ticks = []; - const sampleUntil = Date.now() + 900; - while (Date.now() < sampleUntil) { - ticks.push(pitStats()?.tickMaxMs ?? 0); - await new Promise((r) => setTimeout(r, 100)); - } - assert.ok(Math.max(...ticks) < 80, `tick interval stays ~33 ms while storage is slow: ${ticks}`); - await until(() => saved.length === kills.length, 15_000, 'slow settlements'); - assert.ok(saved[fastKills].waitedMs >= 1400, `waited ${saved[fastKills].waitedMs} ms`); - await pitApi.setStorage(0); - await until(() => pit.profile().kills === kills.length, 5_000, 'profile'); - const before = pit.profile(); + await until(() => (pitStats()?.kills ?? 0) >= kills.length, 5_000, 'room kill count'); + const roomKills = pitStats().kills; - // Graceful restart on the same data dir (the smoke test covers kill -9): - // the guest token stays valid (secret persisted in the data dir) and - // the profile is read back from the cell. + // Through the east doorway: the room hands the player to pit.1.0. + const next = await walkThroughDoor(pit, [1, 0]); + assert.equal(next, 'pit.1.0'); + await until(() => pit.hello()?.roomX === 1, 10_000, 'hello of the next room'); + // The left room empties: dormant, then hibernated. + const status = async (zone) => (await pitApi.rooms()).rooms.find((r) => r.zone === zone)?.status; + await eventually(async () => (await status('pit.0.0')) === 'hibernated', 30_000, 'pit.0.0 hibernated'); + assert.equal((await pitApi.roomInfo('pit.0.0')).awake, false); + assert.equal(await status('pit.1.0'), 'active'); + + // Back west: the room wakes from its snapshot, the dead stay dead. + assert.equal(await walkThroughDoor(pit, [-1, 0]), 'pit.0.0'); + await until(() => pit.hello()?.roomX === 0, 10_000, 'hello of the entrance again'); + assert.equal(pit.hello().restored, true, 'restored from hibernation'); + assert.equal(pit.hello().roomKills, roomKills); + const restored = await eventually(async () => { + const row = (await pitApi.rooms()).rooms.find((r) => r.zone === 'pit.0.0'); + return row?.status === 'active' && row.restored && row; + }, 15_000, 'pit.0.0 active again, restored'); + assert.ok(restored.activations >= 2); + + // The services restart; the profile is read back from the cell. + const before = pit.profile(); pit.client.close(); await server.restart(); - const again = createMobPitClient({ api: new DevApi({ base: server.httpUrl, storage }), base: server.httpUrl }); + const again = createMobPitClient({ api: new DevApi({ base: server.httpUrl, storage }), roomsBase: server.roomsUrl }); clients.push(again.client); - await again.client.connect('pit-1'); - await until(() => again.profile() !== undefined, 5_000, 'profile after restart'); - assert.equal(again.profile().source, 'cell'); + await again.client.connect('pit.0.0'); + await until(() => again.profile() !== undefined, 10_000, 'profile after restart'); assert.equal(again.profile().kills, before.kills); assert.equal(again.profile().xp, before.xp); - // The crawler list is a projection of the cells' kill events. const crawlers = await eventually( async () => { const { crawlers } = await new MobPitApi(server.httpUrl).crawlers(); - return crawlers.length === 1 && crawlers[0].kills === kills.length && crawlers; + return crawlers.length === 1 && crawlers[0].kills === before.kills && crawlers; }, 15_000, 'crawler directory' ); - assert.equal(crawlers[0].kills, kills.length); + assert.equal(crawlers[0].kills, before.kills); } 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 42e6719b..faa17c48 100644 --- a/examples/game/client/tests/prediction-wasm.test.mjs +++ b/examples/game/client/tests/prediction-wasm.test.mjs @@ -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']); + assert.deepEqual(pureSurfaces.map((s) => s.name), ['grid', 'mobpit']); const declared = pureSurfaces.flatMap((s) => s.pures.map((p) => p.export)).sort(); assert.deepEqual(traces.pures.map((p) => p.export).sort(), declared); }); @@ -144,7 +144,7 @@ test('grid: the generated wrappers decode what the native generation encodes', a grid_cell: pureType(generated, 'grid_cell'), grid_sample: pureType(generated, 'grid_sample') }; - for (const pure of traces.pures) { + 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)) { diff --git a/examples/game/client/vite.config.ts b/examples/game/client/vite.config.ts index 405dc7b4..384457ee 100644 --- a/examples/game/client/vite.config.ts +++ b/examples/game/client/vite.config.ts @@ -1,12 +1,15 @@ // Multi-page Vite app: `/` lists the demos, `//` is one demo. // Add a demo: create `/index.html` + `src//main.ts` and list it // in DEMOS. In dev, game traffic is proxied to the Rust `game-demos` server -// (GAME_DEMOS_SERVER, default http://127.0.0.1:2567). +// (GAME_DEMOS_SERVER, default http://127.0.0.1:2567); Mob Pit's rooms +// (`/rooms/{room}`) to celld, which runs them (GAME_DEMOS_ROOMS, default +// http://127.0.0.1:2570). import { resolve } from 'node:path'; import { defineConfig } from 'vite'; const DEMOS = ['arena', 'mobpit', 'leaderboard', 'gallery', 'freeze', 'grid', 'ripple']; const server = process.env.GAME_DEMOS_SERVER ?? 'http://127.0.0.1:2567'; +const rooms = process.env.GAME_DEMOS_ROOMS ?? 'http://127.0.0.1:2570'; export default defineConfig({ server: { @@ -14,6 +17,7 @@ export default defineConfig({ strictPort: true, proxy: { '/zones': { target: server, ws: true }, + '/rooms': { target: rooms, ws: true }, '/dev': server, '/metrics': server }, diff --git a/examples/game/run.sh b/examples/game/run.sh index 51cd396b..210b2b85 100755 --- a/examples/game/run.sh +++ b/examples/game/run.sh @@ -3,7 +3,15 @@ # # ./run.sh arena build what is needed, start the arena server # (dev routes on) + Vite, print the URL -# ./run.sh mobpit same for Mob Pit; `leaderboard` = same server, board page +# ./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 +# Vite; `leaderboard` = same, board page +# ./run.sh mobpit --fleet [N] +# same on a local multi-node celld: N nodes (default +# 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 @@ -14,7 +22,8 @@ # (`distributed game-wasm`, gitignored, stamped so # an unchanged module is not rebuilt); `gen --check` # verifies the TypeScript (and still builds WASM) -# ./run.sh sveltekit Mob Pit in SvelteKit (sveltekit/): dev sign-in, +# ./run.sh sveltekit [--fleet [N]] +# Mob Pit in SvelteKit (sveltekit/): dev sign-in, # session-minted tickets, game client generated by # the Vite plugin, Svelte HUD, and the settled # read model from mobpit-service over GraphQL @live @@ -33,11 +42,14 @@ # uses GAME_DEMOS_PORT, default 2967 there, and its read-model service # MOBPIT_SERVICE_PORT, default GAME_DEMOS_PORT + 2). # -# celld: the demos' durable cells (examples/game/cells: Mob Pit's Crawler -# and presence 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 .data//celld (Mob Pit: -# MOBPIT_DATA_DIR/celld). CELLD_BIN names the binary (default: `celld` on +# 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 +# .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), @@ -142,6 +154,7 @@ kill_tree() { # worker in (its state persists in /.celld/dev), relaying # the outbox Queue to the game server on . Sets CELLD_PID, # CELLD_URL and DISTRIBUTED_INTERNAL_SECRET (persisted in the project). +# CELLD_EXTRA_VARS (newline-terminated `KEY=value` lines) adds worker vars. start_celld() { local project="$1" port="$2" relay_port="$3" ensure_celld @@ -154,8 +167,8 @@ start_celld() { (umask 077 && printf 'test-only-%s' "$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')" >"$project/internal-secret") fi DISTRIBUTED_INTERNAL_SECRET="$(cat "$project/internal-secret")" - printf 'DISTRIBUTED_INTERNAL_SECRET=%s\nCELLD_QUEUE_RELAY_URL=http://127.0.0.1:%s/internal/celld-queue/relay\n' \ - "$DISTRIBUTED_INTERNAL_SECRET" "$relay_port" >"$project/.dev.vars" + printf 'DISTRIBUTED_INTERNAL_SECRET=%s\nCELLD_QUEUE_RELAY_URL=http://127.0.0.1:%s/internal/celld-queue/relay\n%s' \ + "$DISTRIBUTED_INTERNAL_SECRET" "$relay_port" "${CELLD_EXTRA_VARS:-}" >"$project/.dev.vars" log "starting celld dev on :$port ($("$CELLD_BIN" --version 2>/dev/null | grep '^celld ' | head -1); state in $project/.celld/dev)" RUST_LOG=warn "$CELLD_BIN" dev "$project" --host 127.0.0.1 --port "$port" --no-watch --logs >"$project/celld.log" 2>&1 & CELLD_PID=$! @@ -232,6 +245,165 @@ ensure_wasm() { return "$status" } +# mobpit_secret : the rooms' ticket secret, persisted in (the +# services read the same file: guests keep their identity across restarts). +mobpit_secret() { + local file="$1/dev-ticket-secret" + mkdir -p "$1" + if [[ ! -s "$file" ]]; then + (umask 077 && od -An -N32 -tx1 /dev/urandom | tr -d ' \n' >"$file") + fi + cat "$file" +} + +# start_fleet : a local multi-node +# celld in production mode — one RustFS container (docker compose; Dory +# publishes ports for compose, not for plain `docker run`) holding the +# fleet bucket, the cells worker deployed to it (`celld deploy`, vars +# inlined), N nodes. Sets FLEET_PIDS, FLEET_PROJECT, FLEET_COMPOSE, +# CELLD_URL (node-a, for the services), ROOMS_URLS (a round-robin load +# balancer over every node, for browsers: scripts/round-robin.mjs) and +# DISTRIBUTED_INTERNAL_SECRET. +start_fleet() { + local dir="$1" relay_port="$2" nodes="$3" ticket_secret="$4" + local base="${GAME_DEMOS_PORT:-2567}" + local s3_port="${MOBPIT_FLEET_S3_PORT:-$((base + 30))}" + ensure_celld + ensure_cells + for tool in docker aws python3; do + command -v "$tool" >/dev/null || { echo "--fleet needs $tool" >&2; exit 1; } + done + mkdir -p "$dir" + export AWS_ACCESS_KEY_ID=mobpitfleet AWS_SECRET_ACCESS_KEY=mobpitfleetsecret AWS_REGION=us-east-1 + export S3_ENDPOINT="http://127.0.0.1:$s3_port" + FLEET_PROJECT="mobpit-fleet-$base" + FLEET_COMPOSE="$dir/s3-compose.yaml" + cat >"$FLEET_COMPOSE" </dev/null + for _ in $(seq 1 60); do + aws --endpoint-url "$S3_ENDPOINT" s3 mb s3://mobpit-fleet >/dev/null 2>&1 && break + aws --endpoint-url "$S3_ENDPOINT" s3 ls s3://mobpit-fleet >/dev/null 2>&1 && break + sleep 1 + done + if [[ ! -s "$dir/internal-secret" ]]; then + (umask 077 && printf 'test-only-%s' "$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')" >"$dir/internal-secret") + fi + DISTRIBUTED_INTERNAL_SECRET="$(cat "$dir/internal-secret")" + local names=(a b c d e f) nodes_var="" i + for ((i = 0; i < nodes; i++)); do + nodes_var+="${nodes_var:+,}node-${names[$i]}=127.0.0.1:$((base + 20 + i))" + done + rm -rf "$dir/worker" && mkdir -p "$dir/worker" + cp -R "$CELLS/build" "$dir/worker/build" + python3 - "$CELLS/wrangler.jsonc" "$dir/worker/wrangler.json" <&1 | grep -E "Version ID|rror" || true + FLEET_PIDS=() + ROOMS_URLS="" + for ((i = 0; i < nodes; i++)); do + local name="node-${names[$i]}" ndir="$dir/node-${names[$i]}" + mkdir -p "$ndir" + CELLD_NODE="$name" CELLD_WATCH="$ndir/watch" RUST_LOG="${RUST_LOG:-warn}" \ + CELLD_IDLE_EVICT_S="${CELLD_IDLE_EVICT_S:-60}" CELLD_READY_FLEET_GATE_MS=0 \ + "$CELLD_BIN" --bucket s3://mobpit-fleet/fleet --listen "127.0.0.1:$((base + 10 + i))" \ + --internal-listen "127.0.0.1:$((base + 20 + i))" --advertise "127.0.0.1:$((base + 20 + i))" \ + >"$ndir/celld.log" 2>&1 & + FLEET_PIDS+=($!) + ROOMS_URLS+="${ROOMS_URLS:+,}http://127.0.0.1:$((base + 10 + i))" + done + for ((i = 0; i < nodes; i++)); do + for _ in $(seq 1 120); do + curl -fsS "http://127.0.0.1:$((base + 10 + i))/health" >/dev/null 2>&1 && break + sleep 0.5 + done + curl -fsS "http://127.0.0.1:$((base + 10 + i))/health" >/dev/null || { + tail -20 "$dir/node-${names[$i]}/celld.log" + echo "fleet node-${names[$i]} did not start" >&2 + exit 1 + } + log "node-${names[$i]}: http://127.0.0.1:$((base + 10 + i)) (operator :$((base + 20 + i)), logs $dir/node-${names[$i]}/celld.log)" + done + CELLD_URL="http://127.0.0.1:$((base + 10))" + # One entry point for browsers, spreading connections over the nodes + # like a production load balancer would (placement stays celld's). + local lb_port="${MOBPIT_FLEET_LB_PORT:-$((base + 9))}" + node "$HERE/scripts/round-robin.mjs" "$lb_port" "$(echo "$ROOMS_URLS" | sed 's#http://##g')" >"$dir/lb.log" 2>&1 & + FLEET_PIDS+=($!) + for _ in $(seq 1 50); do grep -q LISTENING "$dir/lb.log" && break; sleep 0.1; done + log "load balancer: http://127.0.0.1:$lb_port → $ROOMS_URLS (round robin per connection)" + ROOMS_URLS="http://127.0.0.1:$lb_port" + export CELLD_URL DISTRIBUTED_INTERNAL_SECRET +} + +stop_fleet() { + [[ -n "${FLEET_PROJECT:-}" ]] || return 0 + kill "${FLEET_PIDS[@]}" 2>/dev/null || true + wait "${FLEET_PIDS[@]}" 2>/dev/null || true + docker compose -p "$FLEET_PROJECT" -f "$FLEET_COMPOSE" down -v >/dev/null 2>&1 || true +} + +# Mob Pit: rooms on celld (`celld dev`, or a local fleet with --fleet [N]), +# the services (`game-demos mobpit`: tickets, read models, boards) and Vite. +run_mobpit() { + local demo="$1" fleet="${2:-}" nodes="${3:-3}" + local port="${GAME_DEMOS_PORT:-2567}" + local client_port="${GAME_DEMOS_CLIENT_PORT:-5173}" + ensure_sdk + ensure_client + ensure_server + ensure_wasm + local secret + secret="$(mobpit_secret "$MOBPIT_DATA_DIR")" + CELLD_PID="" + FLEET_PROJECT="" + if [[ "$fleet" == --fleet ]]; then + start_fleet "$MOBPIT_DATA_DIR/fleet" "$port" "$nodes" "$secret" + else + CELLD_EXTRA_VARS="GAME_TICKET_SECRET=$secret"$'\n'"CELLD_ZONE_NODE=celld-dev"$'\n' + start_celld "$MOBPIT_DATA_DIR/celld" "${CELLD_PORT:-$((port + 3))}" "$port" + ROOMS_URLS="$CELLD_URL" + fi + local log_file + log_file="$(mktemp -t game-demos.XXXXXX)" + DISTRIBUTED_GAME_SECRET="$secret" "$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 + wait_listening "$log_file" "$server_pid" "game-demos 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)" + 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}" @@ -356,6 +528,7 @@ require_redis() { # changes). run_sveltekit() { require_redis + local fleet="${1:-}" nodes="${2:-3}" local port="${GAME_DEMOS_PORT:-2967}" local ui_port="${SVELTEKIT_PORT:-5573}" local service_port="${MOBPIT_SERVICE_PORT:-$((port + 2))}" @@ -365,19 +538,28 @@ run_sveltekit() { ensure_server ensure_cli mkdir -p "$data" - start_celld "$data/celld" "${CELLD_PORT:-$((port + 3))}" "$port" - local log_file secret + local secret + secret="$(mobpit_secret "$data")" + CELLD_PID="" + FLEET_PROJECT="" + if [[ "$fleet" == --fleet ]]; then + GAME_DEMOS_PORT="$port" start_fleet "$data/fleet" "$port" "$nodes" "$secret" + else + CELLD_EXTRA_VARS="GAME_TICKET_SECRET=$secret"$'\n'"CELLD_ZONE_NODE=celld-dev"$'\n' + start_celld "$data/celld" "${CELLD_PORT:-$((port + 3))}" "$port" + ROOMS_URLS="$CELLD_URL" + fi + local log_file log_file="$(mktemp -t game-demos-sveltekit.XXXXXX)" - secret="${DISTRIBUTED_GAME_SECRET:-$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')}" - DISTRIBUTED_GAME_SECRET="$secret" "$BIN" mobpit --port "$port" \ + DISTRIBUTED_GAME_SECRET="$secret" "$BIN" mobpit --port "$port" --celld-url "$CELLD_URL" \ --data-dir "$data" --projection-bus "sqlite:$data/bus.db" \ --redis-url "$REDIS_URL" >"$log_file" 2>&1 & local server_pid=$! - trap 'kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true; kill_tree "$CELLD_PID"; rm -f "$log_file"' EXIT INT TERM + 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 wait_listening "$log_file" "$server_pid" "game-demos mobpit" api_keys echo - log "open http://localhost:$ui_port/ (sign in with any username; Mob Pit server :$port, read model :$service_port; dev sign-in — local only)" + log "open http://localhost:$ui_port/ (sign in with any username; rooms on celld ${ROOMS_URLS}, services :$port, read model :$service_port; dev sign-in — local only)" echo # mobpit-service (the lifecycle's runtime) and the UI share these. BIND="127.0.0.1:$service_port" UI_PORT="$ui_port" \ @@ -388,7 +570,7 @@ run_sveltekit() { MOBPIT_UI_ORIGINS="http://localhost:$ui_port,http://127.0.0.1:$ui_port" \ MOBPIT_GRAPHQL_URL="http://127.0.0.1:$service_port/graphql" \ GAME_DEV_AUTH=1 AUTH_SECRET="${AUTH_SECRET:-$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')}" \ - GAME_TICKET_SECRET="$secret" GAME_ZONES_URL="ws://127.0.0.1:$port" \ + GAME_TICKET_SECRET="$secret" GAME_ZONES_URL="$(echo "$ROOMS_URLS" | sed 's#http://#ws://#g')" \ "$CLI" dev "$SERVER" } @@ -431,12 +613,14 @@ smoke() { case "${1:-}" in smoke) smoke ;; gen) gen "${2:-}" ;; - sveltekit) run_sveltekit ;; + sveltekit) run_sveltekit "${2:-}" "${3:-3}" ;; 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 + run_mobpit "$1" "${2:-}" "${3:-3}" elif [[ " ${DEMOS[*]} " == *" $1 "* ]]; then run_demo "$1" else diff --git a/examples/game/scripts/round-robin.mjs b/examples/game/scripts/round-robin.mjs new file mode 100644 index 00000000..7adb49c6 --- /dev/null +++ b/examples/game/scripts/round-robin.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/** + * A dev TCP load balancer in front of a local celld fleet (`run.sh mobpit + * --fleet`): each new connection goes to the next node, round robin, the + * way a production load balancer spreads clients over celld's public + * listeners. It decides nothing about placement: celld places a room on + * the node that first receives it and forwards every later socket to the + * owner, wherever it entered. + * + * node scripts/round-robin.mjs [,…] + * + * Prints `LISTENING ` when ready. + */ +import { connect, createServer } from 'node:net'; + +const [port, list] = process.argv.slice(2); +const targets = (list ?? '').split(',').filter(Boolean).map((t) => { + const [host, p] = t.replace(/^\w+:\/\//, '').split(':'); + return { host, port: Number(p) }; +}); +if (!port || targets.length === 0) { + console.error('usage: round-robin.mjs [,…]'); + process.exit(2); +} +let next = 0; +createServer((client) => { + const target = targets[next++ % targets.length]; + const upstream = connect(target.port, target.host); + client.pipe(upstream).pipe(client); + const close = () => { + client.destroy(); + upstream.destroy(); + }; + client.on('error', close); + upstream.on('error', close); +}).listen(Number(port), '127.0.0.1', () => console.log(`LISTENING ${port}`)); diff --git a/examples/game/server/Cargo.lock b/examples/game/server/Cargo.lock index 7689c67b..54d7518d 100644 --- a/examples/game/server/Cargo.lock +++ b/examples/game/server/Cargo.lock @@ -162,7 +162,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "strum", + "strum 0.27.2", "syn 2.0.119", "thiserror", ] @@ -302,7 +302,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "percent-encoding", @@ -474,6 +474,17 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "js-sys", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "clap" version = "4.6.7" @@ -832,6 +843,7 @@ dependencies = [ "tokio", "tonic-build", "uuid", + "worker", ] [[package]] @@ -844,6 +856,7 @@ dependencies = [ "distributed_game_macros", "futures-util", "hmac 0.12.1", + "js-sys", "reqwest", "serde", "serde_json", @@ -851,6 +864,9 @@ dependencies = [ "tokio", "tokio-tungstenite 0.30.0", "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "worker", ] [[package]] @@ -1119,6 +1135,7 @@ dependencies = [ "game_sim", "http-body-util", "mobpit_crawler", + "mobpit_rooms", "reqwest", "serde", "serde_json", @@ -1665,6 +1682,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "matchit" version = "0.8.4" @@ -1714,6 +1737,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "mobpit_rooms" +version = "0.1.0" +dependencies = [ + "distributed_game", + "game_sim", + "mobpit_crawler", + "serde", + "serde_json", +] + [[package]] name = "mobpit_service" version = "0.1.0" @@ -1910,6 +1944,26 @@ dependencies = [ "pest", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2424,6 +2478,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -2826,7 +2891,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -2841,6 +2915,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3395,6 +3481,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94ce9bdac782bbca70d6ac48a3ba18b873c848a706d6dc3bd9a03c1b96f04693" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.106" @@ -3542,6 +3641,65 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "worker" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057bce8ec66c80b7e270d884c312e2a4ff60eb997fd02936656106c06a964e5" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-util", + "http", + "http-body", + "js-sys", + "matchit 0.7.3", + "pin-project", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_urlencoded", + "strum 0.28.0", + "tokio", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "worker-macros", + "worker-sys", +] + +[[package]] +name = "worker-macros" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d7389670b55aab89760be149007aeff773cdad7b650d807f7fb556d04bb304" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "strum 0.28.0", + "syn 3.0.9", + "wasm-bindgen", + "wasm-bindgen-macro-support", + "worker-sys", +] + +[[package]] +name = "worker-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a440092e7ae7c5feb5507fbdc6aee0cc241d132757c83e1729766a24cddff82" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/examples/game/server/Cargo.toml b/examples/game/server/Cargo.toml index 61c19214..7ee5b13d 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", "service"] +members = [".", "sim", "crawler", "rooms", "service"] resolver = "2" # `distributed build|dev examples/game/server` owns the SvelteKit demo's @@ -29,6 +29,9 @@ path = "src/main.rs" game_sim = { path = "sim" } # The Crawler aggregate + RecordKill (shared with the read-model service). 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" } 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/rooms/Cargo.toml b/examples/game/server/rooms/Cargo.toml new file mode 100644 index 00000000..31212d99 --- /dev/null +++ b/examples/game/server/rooms/Cargo.toml @@ -0,0 +1,28 @@ +# Mob Pit's rooms: the zone (combat, mobs, doorways, hibernation) on the +# seeded room grid. Portable: the celld cells worker hosts every room as a +# Durable Object (`celld` feature, wasm32); `game_demos` links the same zone +# natively for the client surface, unit tests and bots. +[package] +name = "mobpit_rooms" +version = "0.1.0" +edition = "2021" +publish = false +description = "Mob Pit rooms: the zone and its celld object hooks (dev demo)" + +[lib] +path = "src/lib.rs" + +[features] +default = [] +# `CelldZone` for the room zone (wasm32): what the cells worker needs. +celld = ["distributed_game/celld-zone"] + +[dependencies] +distributed_game = { path = "../../../../distributed_game" } +game_sim = { path = "../sim" } +mobpit_crawler = { path = "../crawler" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] +distributed_game = { path = "../../../../distributed_game", features = ["cell"] } diff --git a/examples/game/server/rooms/src/celld.rs b/examples/game/server/rooms/src/celld.rs new file mode 100644 index 00000000..16b7dacf --- /dev/null +++ b/examples/game/server/rooms/src/celld.rs @@ -0,0 +1,51 @@ +//! Rooms as celld Durable Objects: the [`CelldZone`] hooks of the room zone. +//! The cells worker declares the class with +//! `distributed_game::celld_zone_object!(PitRoom, MobPit)`. + +use distributed_game::celld_zone::{CelldZone, LocalFuture, ObjectEnv, ObjectFactSink}; +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::{DEFAULT_SEED, SEED_VAR}; + +impl CelldZone for MobPit { + fn object_config(zone_id: &str, env: &ObjectEnv) -> Result { + let seed = env + .var(SEED_VAR) + .and_then(|seed| seed.trim().parse().ok()) + .unwrap_or(DEFAULT_SEED); + RoomConfig::for_zone(zone_id, seed).ok_or_else(|| format!("no room `{zone_id}`")) + } + + fn host_config(_zone_id: &str, _env: &ObjectEnv) -> HostConfig { + HostConfig { + // Joined / lost / reconnected / left facts for the presence cells. + lifecycle_facts: true, + max_players: 32, + ..HostConfig::default() + } + } + + fn fact_routes(sink: ObjectFactSink) -> ObjectFactSink { + sink.route(FactRoute::by_principal::(CRAWLER_KIND)) + .lifecycle_route(PRESENCE_KIND) + } + + /// 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(); + let principal = principal.to_string(); + Some(Box::pin(async move { + let cell = env.read_cell(CRAWLER_KIND, &principal).await.ok()?; + let state = &cell["state"]; + Some(PitMessage::Loaded { + principal, + xp: state["xp"].as_u64().unwrap_or(0), + level: state["level"].as_u64().unwrap_or(1) as u32, + kills: state["kills"].as_u64().unwrap_or(0) as u32, + }) + })) + } +} diff --git a/examples/game/server/rooms/src/lib.rs b/examples/game/server/rooms/src/lib.rs new file mode 100644 index 00000000..6dd33c17 --- /dev/null +++ b/examples/game/server/rooms/src/lib.rs @@ -0,0 +1,27 @@ +//! **Mob Pit rooms**: a near-infinite seeded grid of rooms (`pit..`) +//! joined by doorways, one zone per room ([`zone`]). +//! +//! On celld every room is a Durable Object (`celld` feature: [`celld`] +//! implements `distributed_game::celld_zone::CelldZone` for the zone; the +//! cells worker declares the class). celld decides where each room runs; +//! the rooms neither know nor care. A room that empties goes dormant, then +//! hibernates with its mobs; coming back restores them. Kills settle as +//! facts in the killer's Crawler cell from any room. +//! +//! The room contents (biome, danger, mobs) come from +//! `game_sim::mobpit::pit_room`, the same code the browser runs as +//! WebAssembly. + +#[cfg(all(feature = "celld", target_arch = "wasm32"))] +pub mod celld; +pub mod zone; + +pub use game_sim::mobpit::{ + parse_room, pit_room, room_zone, PitRoomContext, PitRoomLayout, DEFAULT_SEED, DOOR_HALF, +}; +pub use zone::*; + +/// The room every player starts in. +pub const ENTRANCE: &str = "pit.0.0"; +/// Env var / CLI name of the rooms' world seed. +pub const SEED_VAR: &str = "MOBPIT_SEED"; diff --git a/examples/game/server/rooms/src/zone.rs b/examples/game/server/rooms/src/zone.rs new file mode 100644 index 00000000..ccf53a9f --- /dev/null +++ b/examples/game/server/rooms/src/zone.rs @@ -0,0 +1,1392 @@ +//! One Mob Pit room: authoritative melee combat at 30 Hz. +//! +//! Movement is the Arena's step through [`pit_step`], the declared predicted +//! step the client runs as WebAssembly; combat is server-only. Entities: +//! +//! | kind | ids | fields | +//! |---|---|---| +//! | 1 `player` | 1000 + player | x y vx vy dash cd (predicted) · name hp face swing level dead | +//! | 2 `mob` | 1..=12 | x y face tier hp state tele | +//! | 3 `pillar` | 100 + i | x y r (global) | +//! | 4 `pit` | 900 | pending committed backlog tickMs tickMaxMs kills (global) | +//! +//! The room's mobs come from its seeded layout ([`pit_room`]: tiers and +//! homes); the rest of a room is the Arena floor with a doorway in the +//! middle of each wall. Pushing through a doorway hands the player to the +//! neighbouring room ([`Ctx::handoff`], carrying where along the wall it +//! went through); the client follows the Handoff to the next room's zone, +//! wherever it runs. +//! +//! Mob state machine (deterministic, zone RNG only for wander targets): +//! `idle` (wander near home) → `chase` (living player within aggro radius or +//! hit by one) → `windup` (in reach: stand still, telegraph ring for 0.8 s) → +//! damage everyone inside the ring → `recover` (0.6 s) → `chase`; beyond the +//! leash or target dead → `return` home → `idle`. Dead mobs respawn after 4 s +//! with a new generation. +//! +//! A kill emits a typed fact **without awaiting storage**: the Crawler +//! cell command's own input, `RecordKillInput { mob, tier, name }`, keyed by +//! `m{mob}` and its generation and emitted for the killer (the host binds +//! the verified principal; the cell decides the XP). The settlement arrives +//! in [`Zone::on_fact_settled`] on a later tick and is pushed to the killer +//! as [`PitEvent::Saved`] + [`PitEvent::Profile`]. +//! +//! **Hibernation.** [`Zone::hibernate`] keeps every mob (position, HP, +//! state, generation, respawn timer) and the room's kill count; a room that +//! wakes up restores them ([`Zone::on_restore`]) and tells joining players +//! ([`PitEvent::Hello::restored`]). + +use std::collections::{BTreeMap, HashMap, VecDeque}; +use std::time::Duration; + +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::mobpit::{ + arrival, doorway, parse_room, pit_room, pit_world, room_zone, PitRoomContext, PitRoomFn, +}; +use mobpit_crawler::{level_floor, KillReceipt, RecordKillInput}; +use serde::{Deserialize, Serialize}; + +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 +/// state. +#[derive(Debug, Clone, PartialEq, Replicated)] +#[replicated(kind = "player", id = 1, priority = 2.0, of = Player)] +pub struct PlayerView { + /// Position x. + #[field_type(movement::POS)] + #[correct(ease = 150)] + #[from(state.x)] + pub x: f32, + /// Position y. + #[field_type(movement::POS)] + #[correct(ease = 150)] + #[from(state.y)] + pub y: f32, + /// Velocity x. + #[field_type(movement::VEL)] + #[correct(snap)] + #[from(state.vx)] + pub vx: f32, + /// Velocity y. + #[field_type(movement::VEL)] + #[correct(snap)] + #[from(state.vy)] + pub vy: f32, + /// Remaining dash frames. + #[correct(snap)] + #[from(state.dash)] + pub dash: u8, + /// Remaining dash cooldown frames. + #[correct(snap)] + #[from(state.cd)] + pub cd: u8, + /// Display name. + #[priority(0.2)] + #[correct(replace)] + #[from] + pub name: String, + /// Hit points. + #[correct(replace)] + pub hp: u8, + /// Facing (rad). + #[angle(bits = 8)] + #[correct(replace)] + #[from] + pub face: f32, + /// Swing animation frames left. + #[correct(replace)] + pub swing: u8, + /// Level (from the durable cell). + #[correct(replace)] + pub level: u16, + /// Waiting to respawn. + #[correct(replace)] + pub dead: bool, +} + +/// A mob (dead mobs are not replicated). +#[derive(Debug, Clone, PartialEq, Replicated)] +#[replicated(kind = "mob", id = 2, of = Mob)] +pub struct MobView { + /// Position x. + #[quant(min = -movement::HALF, max = movement::HALF, bits = 14)] + #[from] + pub x: f32, + /// Position y. + #[quant(min = -movement::HALF, max = movement::HALF, bits = 14)] + #[from] + pub y: f32, + /// Facing (rad). + #[angle(bits = 8)] + #[from] + pub face: f32, + /// 1 grunt · 2 brute · 3 elite. + #[from] + pub tier: u8, + /// HP percent. + pub hp: u8, + /// 0 idle · 1 chase · 2 windup · 3 recover · 4 return. + pub state: u8, + /// Telegraph progress 0..=255 during a windup. + pub tele: u8, +} + +/// A pillar of the floor (visible everywhere). +#[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 { + 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 pillar, projected as is. +#[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, +} + +/// Room numbers for the HUD (global), projected from the zone itself. +#[derive(Debug, Clone, PartialEq, Replicated)] +#[replicated(kind = "pit", id = 4, global, of = MobPit)] +pub struct PitView { + /// Facts emitted and not settled. + pub pending: u16, + /// Settlements received. + pub committed: u32, + /// Facts waiting for backpressure to clear. + pub backlog: u16, + /// Tick interval EMA (ms). + pub tick_ms: f32, + /// Longest tick interval of the last 30 ticks (ms). + pub tick_max_ms: f32, + /// Mobs killed in this room (kept across hibernation). + pub kills: u32, +} + +/// A player's durable progress (from the cell on join, after each settlement). +#[derive(Debug, Clone, PartialEq, GameRecord)] +pub struct Profile { + /// Total XP. + pub xp: u64, + /// Level. + pub level: u32, + /// Kills. + pub kills: u32, + /// First XP of the level. + pub level_xp: u64, + /// First XP of the next level. + pub next_level_xp: u64, + /// `"cell"` (loaded on join) or `"settled"` (after a kill committed). + pub source: String, +} + +/// Mob Pit's events. +#[derive(Debug, Clone, PartialEq, GameEvent)] +pub enum PitEvent { + /// To a joining player. + #[game_event(id = 1)] + Hello { + /// Own entity id. + entity: u32, + /// Floor half extent (m). + half: f32, + /// Melee reach (m). + attack_range: f32, + /// Cosine of the half cone angle. + attack_arc_cos: f32, + /// Room x. + room_x: i32, + /// Room y. + room_y: i32, + /// World seed of the rooms. + seed: u32, + /// This activation of the room restored a hibernated snapshot. + restored: bool, + /// Mobs killed in this room so far. + room_kills: u32, + }, + /// Durable progress. + #[game_event(id = 2)] + Profile(Profile), + /// Broadcast the tick a mob dies (the fact is only queued). + #[game_event(id = 3)] + Kill { + /// Fact key. + key: String, + /// Mob entity id. + mob: u32, + /// Mob tier. + tier: u8, + /// Where it died. + x: f32, + /// Where it died. + y: f32, + /// Killer entity id. + killer: u32, + /// Killer display name. + killer_name: String, + }, + /// To the killer when its fact settled. + #[game_event(id = 4)] + Saved { + /// Fact key. + key: String, + /// Committed. + ok: bool, + /// The cell recognised the key (replayed receipt). + replayed: bool, + /// XP credited by this kill (decided by the cell). + xp: u32, + /// Total XP after it. + total_xp: u64, + /// Kills after it. + kills: u32, + /// Level after it. + level: u32, + /// Time from emit to settlement (ms). + waited_ms: u32, + /// Where the mob died. + x: f32, + /// Where the mob died. + y: f32, + /// Failure, if any. + error: Option, + /// Commit attempts (failures). + attempts: u32, + }, + /// To a player that died. + #[game_event(id = 6)] + Died { + /// Respawn delay (ms). + respawn_ms: u32, + }, +} + +/// What services deliver into a room. +#[derive(Debug, Clone, PartialEq, GameEvent)] +pub enum PitMessage { + /// A player's durable progress, read from its Crawler cell after it + /// joined (off the tick). + #[game_event(id = 1)] + Loaded { + /// The player's verified principal. + principal: String, + /// Total XP. + xp: u64, + /// Level. + level: u32, + /// Kills. + kills: u32, + }, +} + +/// Mob Pit's client surface (also used by the leaderboard page). +pub fn game_surface() -> GameSurface { + GameSurface::for_zone::("mobpit") + .events::() + .predict::() + .pure::() + .constant("pit_rules", &pit_rules()) + .constant("pit_world", &pit_world()) +} + +/// Input bit: attack (bit 0 is the Arena's dash). +pub const BUTTON_ATTACK: u8 = PitInput::ATTACK; +/// Melee reach (m, from the player's centre to the mob's edge). +pub const ATTACK_RANGE: f32 = 2.2; +/// Cosine of the half cone angle (60°). +pub const ATTACK_ARC_COS: f32 = 0.5; +/// Damage per swing. +pub const ATTACK_DAMAGE: i32 = 10; +/// Swing cooldown (ticks). +pub const SWING_COOLDOWN: u8 = 13; +/// Swing animation length (ticks). +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; +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; +const PLAYER_RESPAWN_TICKS: u64 = 90; + +/// Mob tier stats: (max hp, speed m/s, damage, radius, telegraph radius). +fn tier_stats(tier: u8) -> (i32, f32, i32, f32, f32) { + match tier { + 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]; +/// Names of the `mob` kind's `state` values, by value. +pub const MOB_STATES: [&str; 6] = ["idle", "chase", "windup", "recover", "return", "dead"]; + +/// What the page draws of a mob tier (from [`tier_stats`]). +#[derive(Debug, Clone, Copy, PartialEq, GameRecord)] +pub struct MobTier { + /// Tier (the `mob` kind's `tier`). + pub tier: u8, + /// Body radius (m). + pub radius: f32, + /// Telegraph radius (m). + pub tele: f32, +} + +/// Mob Pit rules the client draws with (generated `PIT_RULES`). +#[derive(Debug, Clone, PartialEq, GameRecord)] +pub struct PitRules { + /// Every tier. + pub tiers: Vec, + /// `state` names by value. + pub states: Vec, + /// Melee reach (m, from the player's centre to the mob's edge). + pub attack_range: f32, + /// Half angle of the swing cone (radians). + pub attack_half_angle: f32, + /// Swing animation length (ticks; the player's `swing` counts down from it). + pub swing_frames: u8, +} + +/// The `PIT_RULES` constant. +pub fn pit_rules() -> PitRules { + PitRules { + tiers: TIERS + .iter() + .map(|&tier| { + let (_, _, _, radius, tele) = tier_stats(tier); + MobTier { tier, radius, tele } + }) + .collect(), + states: MOB_STATES.map(String::from).to_vec(), + attack_range: ATTACK_RANGE, + attack_half_angle: ATTACK_ARC_COS.acos(), + swing_frames: SWING_FRAMES, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum MobState { + Idle = 0, + Chase = 1, + Windup = 2, + Recover = 3, + Return = 4, + Dead = 5, +} + +struct Mob { + id: EntityId, + tier: u8, + home: (f32, f32), + x: f32, + y: f32, + face: f32, + hp: i32, + state: MobState, + timer: u16, + target: Option, + generation: u32, + respawn_at: u64, + wander: (f32, f32), +} + +struct Player { + id: EntityId, + principal: String, + name: String, + state: MoveState, + credits: u8, + hp: i32, + face: f32, + swing_cd: u8, + swing_anim: u8, + dead_until: Option, + level: u32, + regen: u8, + /// Stick of the last input (doorways need the push direction). + stick: (i8, i8), +} + +struct PendingKill { + principal: String, + x: f32, + y: f32, + emitted: Duration, +} + +/// Which room. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RoomConfig { + /// Room coordinates. + pub room: (i32, i32), + /// World seed. + pub seed: u32, +} + +impl RoomConfig { + /// The room of zone `pit..` in world `seed`. + pub fn for_zone(zone_id: &str, seed: u32) -> Option { + parse_room(zone_id).map(|room| Self { room, seed }) + } +} + +/// What a room keeps while it hibernates. +#[derive(Debug, Default, Serialize, Deserialize)] +struct Snapshot { + kills: u32, + mobs: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SavedMob { + id: u32, + x: f32, + y: f32, + face: f32, + hp: i32, + dead: bool, + generation: u32, + /// Ticks until a dead mob respawns. + respawn_in: u64, +} + +/// The Mob Pit room zone. +pub struct MobPit { + room: RoomConfig, + players: BTreeMap, + mobs: Vec, + pillars: Vec, + pending: HashMap, + /// Facts refused by backpressure, retried each tick (oldest first). + backlog: VecDeque<(PlayerId, Fact, f32, f32)>, + last_tick: Option, + tick_ms: f32, + tick_max_ms: f32, + tick_max_window: f32, + window_ticks: u32, + committed: u32, + kills: u32, + restored: bool, + /// The last tick run (hibernation stores timers relative to it). + tick: u64, +} + +fn spawn_point(player: PlayerId) -> (f32, f32) { + const SPOTS: [(f32, f32); 6] = [ + (-4.5, -4.5), + (4.5, -4.5), + (4.5, 4.5), + (-4.5, 4.5), + (0.0, -5.5), + (0.0, 5.5), + ]; + 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 { + let (ox, oy) = (*x - px, *y - py); + let d2 = ox * ox + oy * oy; + let min = radius + pr; + if d2 < min * min { + let d = d2.sqrt().max(1e-4); + *x = px + ox / d * min; + *y = py + oy / d * min; + } + } + let lim = movement::HALF - radius; + *x = x.clamp(-lim, lim); + *y = y.clamp(-lim, lim); +} + +fn step_toward(x: &mut f32, y: &mut f32, tx: f32, ty: f32, stride: f32) -> f32 { + let (dx, dy) = (tx - *x, ty - *y); + let d = (dx * dx + dy * dy).sqrt(); + if d > 1e-4 { + let s = stride.min(d); + *x += dx / d * s; + *y += dy / d * s; + } + dy.atan2(dx) +} + +fn dist(a: (f32, f32), b: (f32, f32)) -> f32 { + ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() +} + +/// Carried through a doorway: the room offset it came from and where along +/// the wall it went through. +fn encode_carried(from: (i32, i32), along: f32) -> Vec { + let mut out = vec![from.0 as i8 as u8, from.1 as i8 as u8]; + out.extend_from_slice(&along.to_le_bytes()); + out +} + +fn decode_carried(bytes: &[u8]) -> Option<((i32, i32), f32)> { + let [dx, dy, a, b, c, d] = bytes else { + return None; + }; + Some(( + (i32::from(*dx as i8), i32::from(*dy as i8)), + f32::from_le_bytes([*a, *b, *c, *d]), + )) +} + +impl MobPit { + /// The room this zone is. + pub fn room(&self) -> RoomConfig { + self.room + } + + fn profile_event(xp: u64, level: u32, kills: u32, source: &str) -> PitEvent { + PitEvent::Profile(Profile { + xp, + level, + kills, + level_xp: level_floor(level), + next_level_xp: level_floor(level + 1), + source: source.into(), + }) + } + + fn player_by_principal(&self, principal: &str) -> Option { + self.players + .iter() + .find(|(_, p)| p.principal == principal) + .map(|(id, _)| *id) + } + + fn emit_kill(&mut self, ctx: &mut Ctx<'_, Self>, killer: PlayerId, mob: usize) { + let m = &self.mobs[mob]; + let (x, y, tier, id, generation) = (m.x, m.y, m.tier, m.id.get(), m.generation); + let Some(player) = self.players.get(&killer) else { + return; + }; + self.kills += 1; + // The cell command's own input: what died, never who or how much XP. + let input = RecordKillInput { + mob: format!("{}/m{id}", ctx.zone_id()), + tier, + name: player.name.clone(), + }; + let Ok(fact) = ctx.fact_with_key(&input, &[&format_args!("m{id}"), &generation]) else { + return; + }; + let key = fact.key().to_string(); + ctx.broadcast(&PitEvent::Kill { + key, + mob: id, + tier, + x, + y, + killer: player.id.get(), + killer_name: player.name.clone(), + }); + self.backlog.push_back((killer, fact, x, y)); + self.flush_backlog(ctx); + } + + /// Hand queued facts to the host; never blocks. Backpressure keeps the + /// rest for the next tick. + fn flush_backlog(&mut self, ctx: &mut Ctx<'_, Self>) { + while let Some((player, fact, x, y)) = self.backlog.pop_front() { + let key = fact.key().to_string(); + match ctx.emit_fact_for(player, fact.clone()) { + Ok(()) => { + let principal = self + .players + .get(&player) + .map(|p| p.principal.clone()) + .unwrap_or_default(); + self.pending.insert( + key, + PendingKill { + principal, + x, + y, + emitted: ctx.now(), + }, + ); + } + Err(EmitError::Backpressure) => { + self.backlog.push_front((player, fact, x, y)); + return; + } + // The killer left before the fact could be queued, or the key + // is already in flight: nothing durable to do. + Err(_) => {} + } + } + } + + fn step_players(&mut self, ctx: &mut Ctx<'_, Self>) -> Vec<(PlayerId, f32, f32, f32)> { + let tick = ctx.tick(); + let mut swings = Vec::new(); + 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.swing_anim = p.swing_anim.saturating_sub(1); + if let Some(until) = p.dead_until { + // Dead: inputs are consumed but ignored. + let _ = ctx.take_inputs(*id, ConsumePolicy::Drain); + if tick >= until { + let (x, y) = spawn_point(*id); + p.state = MoveState { + x, + y, + ..MoveState::default() + }; + p.hp = PLAYER_HP; + p.dead_until = None; + } + continue; + } + while p.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. + p.state = pit_step(&p.state, &input, &(), movement::DT); + p.credits -= 1; + p.stick = (input.mx, input.my); + let (sx, sy) = (f32::from(input.mx), f32::from(input.my)); + if sx * sx + sy * sy > 20.0 * 20.0 { + p.face = sy.atan2(sx); + } + if input.attack() && p.swing_cd == 0 { + p.swing_cd = SWING_COOLDOWN; + p.swing_anim = SWING_FRAMES; + swings.push((*id, p.state.x, p.state.y, p.face)); + } + } + p.regen = p.regen.wrapping_add(1); + if p.regen % 15 == 0 && p.hp < PLAYER_HP { + p.hp += 1; + } + ctx.set_view(*id, View::new(p.state.x, p.state.y, VIEW_RADIUS)); + } + swings + } + + /// Players pushing through a doorway move to the neighbouring room. + fn walk_through_doorways(&mut self, ctx: &mut Ctx<'_, Self>) { + let (rx, ry) = self.room.room; + let leaving: Vec<(PlayerId, (i32, i32), f32)> = self + .players + .iter() + .filter(|(_, p)| p.dead_until.is_none()) + .filter_map(|(id, p)| { + let step = doorway(p.state.x, p.state.y, p.stick.0, p.stick.1)?; + let along = if step.0 != 0 { p.state.y } else { p.state.x }; + Some((*id, step, along)) + }) + .collect(); + for (player, (dx, dy), along) in leaving { + let target = room_zone((rx + dx, ry + dy)); + if parse_room(&target).is_none() { + continue; + } + // The neighbour sees where it came from (the opposite side). + let carried = encode_carried((-dx, -dy), along); + let _ = ctx.handoff(player, &target, carried); + } + } + + fn resolve_swings(&mut self, ctx: &mut Ctx<'_, Self>, swings: Vec<(PlayerId, f32, f32, f32)>) { + for (player, x, y, face) in swings { + let (fx, fy) = (face.cos(), face.sin()); + for i in 0..self.mobs.len() { + let m = &mut self.mobs[i]; + if m.state == MobState::Dead { + continue; + } + let (_, _, _, radius, _) = tier_stats(m.tier); + let (dx, dy) = (m.x - x, m.y - y); + let d = (dx * dx + dy * dy).sqrt(); + if d > ATTACK_RANGE + radius { + continue; + } + let cos = if d < 1e-3 { + 1.0 + } else { + (dx * fx + dy * fy) / d + }; + 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); + } + } + } + } + + fn step_mobs(&mut self, ctx: &mut Ctx<'_, Self>) { + let dt = ctx.dt_secs(); + let tick = ctx.tick(); + let living: Vec<(PlayerId, f32, f32)> = self + .players + .iter() + .filter(|(_, p)| p.dead_until.is_none()) + .map(|(id, p)| (*id, p.state.x, p.state.y)) + .collect(); + let mut hits: Vec<(PlayerId, i32)> = Vec::new(); + for i in 0..self.mobs.len() { + let (max_hp, speed, damage, radius, tele) = tier_stats(self.mobs[i].tier); + 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]; + match m.state { + MobState::Dead => { + if tick >= m.respawn_at { + m.generation += 1; + m.hp = max_hp; + (m.x, m.y) = m.home; + m.state = MobState::Idle; + } + continue; + } + MobState::Idle => { + let nearest = living + .iter() + .map(|(id, x, y)| (*id, dist((m.x, m.y), (*x, *y)))) + .filter(|(_, d)| *d < AGGRO) + .min_by(|a, b| a.1.total_cmp(&b.1)); + if let Some((id, _)) = nearest { + m.state = MobState::Chase; + m.target = Some(id); + } else if dist((m.x, m.y), m.wander) < 0.3 { + let a = ctx.rng().next_f32() * std::f32::consts::TAU; + let r = ctx.rng().next_f32() * 2.5; + let m = &mut self.mobs[i]; + m.wander = (m.home.0 + r * a.cos(), m.home.1 + r * a.sin()); + } else { + let (wx, wy) = m.wander; + m.face = step_toward(&mut m.x, &mut m.y, wx, wy, 1.0 * dt); + } + } + MobState::Chase => match target_pos(m.target) { + Some((tx, ty)) if dist(m.home, (m.x, m.y)) < LEASH => { + let reach = radius + PLAYER_RADIUS + 0.6; + if dist((m.x, m.y), (tx, ty)) <= reach { + m.state = MobState::Windup; + 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.state = MobState::Return; + m.target = None; + } + }, + MobState::Windup => { + 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 { + hits.push((*id, damage)); + } + } + m.state = MobState::Recover; + m.timer = RECOVER_TICKS; + } + } + MobState::Recover => { + m.timer = m.timer.saturating_sub(1); + if m.timer == 0 { + m.state = MobState::Chase; + } + } + 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 dist((m.x, m.y), m.home) < 0.3 { + m.state = MobState::Idle; + m.wander = m.home; + } + } + } + let m = &mut self.mobs[i]; + push_out_of_pillars(&mut m.x, &mut m.y, radius); + } + // Light separation so a pack does not stack on one spot. + for i in 0..self.mobs.len() { + for j in (i + 1)..self.mobs.len() { + let (a, b) = (&self.mobs[i], &self.mobs[j]); + if a.state == MobState::Dead || b.state == MobState::Dead { + continue; + } + let min = tier_stats(a.tier).3 + tier_stats(b.tier).3; + let (dx, dy) = (b.x - a.x, b.y - a.y); + let d = (dx * dx + dy * dy).sqrt(); + if d < min && d > 1e-4 { + let push = (min - d) * 0.5; + let (nx, ny) = (dx / d * push, dy / d * push); + self.mobs[i].x -= nx; + self.mobs[i].y -= ny; + self.mobs[j].x += nx; + self.mobs[j].y += ny; + } + } + } + for (id, damage) in hits { + let Some(p) = self.players.get_mut(&id) else { + continue; + }; + if p.dead_until.is_some() { + continue; + } + p.hp -= damage; + if p.hp <= 0 { + p.hp = 0; + p.state.vx = 0.0; + p.state.vy = 0.0; + p.dead_until = Some(tick + PLAYER_RESPAWN_TICKS); + for m in &mut self.mobs { + if m.target == Some(id) { + m.target = None; + } + } + ctx.send( + id, + &PitEvent::Died { + respawn_ms: (PLAYER_RESPAWN_TICKS * 1000 / 30) as u32, + }, + ); + } + } + } + + fn measure_tick(&mut self, now: Duration) { + if let Some(last) = self.last_tick { + let ms = now.saturating_sub(last).as_secs_f32() * 1000.0; + self.tick_ms = if self.tick_ms == 0.0 { + ms + } else { + self.tick_ms * 0.9 + ms * 0.1 + }; + self.tick_max_window = self.tick_max_window.max(ms); + self.window_ticks += 1; + if self.window_ticks >= 30 { + self.tick_max_ms = self.tick_max_window; + self.tick_max_window = 0.0; + self.window_ticks = 0; + } + } + self.last_tick = Some(now); + } + + /// Admit `player` at `(x, y)` and greet it. + fn admit(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId, (x, y): (f32, f32)) { + let (principal, name) = ctx + .player(player) + .map(|p| { + ( + p.principal.clone(), + p.name.clone().unwrap_or_else(|| p.principal.clone()), + ) + }) + .unwrap_or_default(); + let entity = EntityId(1000 + player.0); + self.players.insert( + player, + Player { + id: entity, + principal, + name, + state: MoveState { + x, + y, + ..MoveState::default() + }, + credits: MAX_CREDITS, + hp: PLAYER_HP, + face: 0.0, + swing_cd: 0, + swing_anim: 0, + dead_until: None, + level: 1, + regen: 0, + stick: (0, 0), + }, + ); + ctx.set_view(player, View::new(x, y, VIEW_RADIUS)); + ctx.send( + player, + &PitEvent::Hello { + entity: entity.get(), + half: movement::HALF, + attack_range: ATTACK_RANGE, + attack_arc_cos: ATTACK_ARC_COS, + room_x: self.room.room.0, + room_y: self.room.room.1, + seed: self.room.seed, + restored: self.restored, + room_kills: self.kills, + }, + ); + } +} + +impl Zone for MobPit { + type Config = RoomConfig; + type Input = PitInput; + type Timer = (); + type Message = PitMessage; + + fn schema() -> EntitySchema { + schema![PlayerView, MobView, PillarView, PitView] + } + + fn entities() -> Entities { + Entities::new() + .each(|z: &Self| &z.players) + .each(|z: &Self| &z.mobs) + .each(|z: &Self| &z.pillars) + .one(|z: &Self| z) + } + + fn on_create(_ctx: &mut Ctx<'_, Self>, room: RoomConfig) -> Self { + let layout = pit_room(&PitRoomContext { + seed: room.seed, + x: room.room.0, + y: room.room.1, + }); + let mobs = layout + .mobs + .iter() + .enumerate() + .map(|(i, slot)| { + let home = (slot.x, slot.y); + Mob { + id: EntityId(i as u32 + 1), + tier: slot.tier, + home, + x: home.0, + y: home.1, + face: 0.0, + hp: tier_stats(slot.tier).0, + state: MobState::Idle, + timer: 0, + target: None, + generation: 0, + respawn_at: 0, + wander: home, + } + }) + .collect(); + Self { + room, + players: BTreeMap::new(), + mobs, + pillars: Pillar::all(), + pending: HashMap::new(), + backlog: VecDeque::new(), + last_tick: None, + tick_ms: 0.0, + tick_max_ms: 0.0, + tick_max_window: 0.0, + window_ticks: 0, + committed: 0, + kills: 0, + restored: false, + tick: 0, + } + } + + fn on_join(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId) { + self.admit(ctx, player, spawn_point(player)); + } + + fn on_handoff_join( + &mut self, + ctx: &mut Ctx<'_, Self>, + player: PlayerId, + _from_zone: &str, + carried: Vec, + ) { + let at = decode_carried(&carried) + .map(|(from, along)| arrival(from, along)) + .unwrap_or_else(|| spawn_point(player)); + // Still here inside its reconnect grace (a doorway bounce): replace. + self.players.remove(&player); + self.admit(ctx, player, at); + } + + fn on_leave(&mut self, _ctx: &mut Ctx<'_, Self>, player: PlayerId, _reason: LeaveReason) { + self.players.remove(&player); + for m in &mut self.mobs { + if m.target == Some(player) { + m.target = None; + } + } + } + + fn on_observe(&mut self, ctx: &mut Ctx<'_, Self>, observer: ObserverId) { + ctx.set_view(observer, View::new(0.0, 0.0, VIEW_RADIUS)); + } + + fn decode_input(&self, _player: PlayerId, payload: &[u8]) -> Option { + PitInput::from_payload(payload) + } + + fn on_tick(&mut self, ctx: &mut Ctx<'_, Self>) { + self.tick = ctx.tick(); + self.measure_tick(ctx.now()); + let swings = self.step_players(ctx); + self.resolve_swings(ctx, swings); + self.step_mobs(ctx); + self.walk_through_doorways(ctx); + self.flush_backlog(ctx); + } + + fn on_message(&mut self, ctx: &mut Ctx<'_, Self>, message: PitMessage) { + match message { + PitMessage::Loaded { + principal, + xp, + level, + kills, + } => { + let Some(id) = self.player_by_principal(&principal) else { + return; + }; + if let Some(p) = self.players.get_mut(&id) { + p.level = level.max(1); + } + ctx.send(id, &Self::profile_event(xp, level.max(1), kills, "cell")); + } + } + } + + fn on_fact_settled(&mut self, ctx: &mut Ctx<'_, Self>, settlement: FactSettlement) { + // The host's own lifecycle facts (presence) are not kills. + if distributed_game::lifecycle::LIFECYCLE_COMMANDS.contains(&settlement.command.as_str()) { + return; + } + let pending = self.pending.remove(&settlement.key); + let now = ctx.now(); + let (principal, x, y, waited_ms) = match &pending { + Some(p) => ( + p.principal.clone(), + p.x, + p.y, + now.saturating_sub(p.emitted) + .as_millis() + .min(u128::from(u32::MAX)) as u32, + ), + None => (String::new(), 0.0, 0.0, 0), + }; + let player = self.player_by_principal(&principal); + match settlement.outcome { + FactOutcome::Committed(receipt) => { + self.committed += 1; + let Ok(kill) = receipt.payload_as::() else { + return; + }; + let Some(player) = player else { return }; + if let Some(p) = self.players.get_mut(&player) { + p.level = kill.level; + } + ctx.send( + player, + &PitEvent::Saved { + key: settlement.key, + ok: true, + replayed: receipt.replayed, + xp: kill.xp, + total_xp: kill.total_xp, + kills: kill.kills, + level: kill.level, + waited_ms, + x, + y, + error: None, + attempts: 0, + }, + ); + ctx.send( + player, + &Self::profile_event(kill.total_xp, kill.level, kill.kills, "settled"), + ); + } + FactOutcome::Failed { error, attempts } => { + if let Some(player) = player { + ctx.send( + player, + &PitEvent::Saved { + key: settlement.key, + ok: false, + replayed: false, + xp: 0, + total_xp: 0, + kills: 0, + level: 0, + waited_ms, + x, + y, + error: Some(format!("{error:?}")), + attempts, + }, + ); + } + } + } + } + + fn has_pending_work(&self) -> bool { + !self.backlog.is_empty() + } + + fn hibernate(&self) -> Option> { + // Called between ticks; timers are relative to the last tick. + let tick = self.tick; + let snapshot = Snapshot { + kills: self.kills, + mobs: self + .mobs + .iter() + .map(|m| SavedMob { + id: m.id.get(), + x: m.x, + y: m.y, + face: m.face, + hp: m.hp, + dead: m.state == MobState::Dead, + generation: m.generation, + respawn_in: m.respawn_at.saturating_sub(tick), + }) + .collect(), + }; + serde_json::to_vec(&snapshot).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.kills = snapshot.kills; + self.restored = true; + let tick = ctx.tick(); + for saved in snapshot.mobs { + let Some(m) = self.mobs.iter_mut().find(|m| m.id.get() == saved.id) else { + continue; + }; + m.generation = saved.generation; + m.face = saved.face; + if saved.dead { + m.state = MobState::Dead; + m.hp = 0; + m.respawn_at = tick + saved.respawn_in; + } else { + // 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.state = MobState::Return; + } + } + } +} + +impl Entity for Player { + fn entity_id(&self) -> EntityId { + self.id + } +} + +impl Project for Player { + fn view(&self) -> Option { + Some(PlayerView::project( + self, + PlayerViewRest { + hp: self.hp.clamp(0, 255) as u8, + swing: self.swing_anim, + level: self.level.min(u32::from(u16::MAX)) as u16, + dead: self.dead_until.is_some(), + }, + )) + } +} + +impl Entity for Mob { + fn entity_id(&self) -> EntityId { + self.id + } +} + +impl Project for Mob { + fn view(&self) -> Option { + if self.state == MobState::Dead { + return None; + } + let max_hp = tier_stats(self.tier).0; + let tele = if self.state == MobState::Windup { + 255 - (u32::from(self.timer) * 255 / u32::from(WINDUP_TICKS)) + } else { + 0 + }; + Some(MobView::project( + self, + MobViewRest { + hp: (self.hp.max(0) * 100 / max_hp) as u8, + state: self.state as u8, + tele: tele as u8, + }, + )) + } +} + +impl Entity for MobPit { + fn entity_id(&self) -> EntityId { + PIT_ENTITY + } +} + +impl Project for MobPit { + fn view(&self) -> Option { + let clamp16 = |n: usize| n.min(65_535) as u16; + Some(PitView::project( + self, + PitViewRest { + pending: clamp16(self.pending.len()), + committed: self.committed, + backlog: clamp16(self.backlog.len()), + tick_ms: self.tick_ms, + tick_max_ms: self.tick_max_ms, + kills: self.kills, + }, + )) + } +} + +/// Room numbers for tests and tooling. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PitStats { + /// Facts emitted and not yet settled (incl. backpressure backlog). + pub pending: usize, + /// Tick interval EMA (ms). + pub tick_ms: f32, + /// Longest tick interval of the last 30 ticks (ms). + pub tick_max_ms: f32, + /// Settlements received. + pub committed: u32, + /// Living mobs. + pub mobs_alive: usize, + /// Players. + pub players: usize, + /// Mobs killed in the room. + pub kills: u32, + /// The room came back from a snapshot. + pub restored: bool, +} + +impl MobPit { + /// Current numbers. + pub fn stats(&self) -> PitStats { + PitStats { + pending: self.pending.len() + self.backlog.len(), + tick_ms: self.tick_ms, + tick_max_ms: self.tick_max_ms, + committed: self.committed, + mobs_alive: self + .mobs + .iter() + .filter(|m| m.state != MobState::Dead) + .count(), + players: self.players.len(), + kills: self.kills, + restored: self.restored, + } + } + + /// Mob positions `(id, x, y, alive)` (tests and bots). + pub fn mob_positions(&self) -> Vec<(u32, f32, f32, bool)> { + self.mobs + .iter() + .map(|m| (m.id.get(), m.x, m.y, m.state != MobState::Dead)) + .collect() + } + + /// A player's position (tests and bots). + pub fn player_position(&self, player: PlayerId) -> Option<(f32, f32)> { + self.players.get(&player).map(|p| (p.state.x, p.state.y)) + } +} + +/// Bot payload: the direction as a move plus attack always held. +pub fn encode_bot_input(dir: [f32; 2]) -> Vec { + let axis = |v: f32| (v.clamp(-1.0, 1.0) * 127.0).round() as i8; + PitInput { + mx: axis(dir[0]), + my: axis(dir[1]), + buttons: PitInput::ATTACK, + } + .to_payload() +} diff --git a/examples/game/server/rooms/tests/rooms.rs b/examples/game/server/rooms/tests/rooms.rs new file mode 100644 index 00000000..474fdf45 --- /dev/null +++ b/examples/game/server/rooms/tests/rooms.rs @@ -0,0 +1,164 @@ +//! The room zone on the reference host (`ZoneHost`, manual clock): what a +//! celld room object runs, without celld. + +use std::sync::Arc; +use std::time::Duration; + +use distributed_game::protocol::ServerMessage; +use distributed_game::{ + ClientId, FactQueue, GameEvent, HostConfig, ManualClock, Outbound, Role, TicketClaims, Viewer, + ZoneHost, +}; +use mobpit_rooms::{encode_bot_input, MobPit, PitEvent, PitInput, RoomConfig, DEFAULT_SEED}; + +fn host(zone: &str, clock: &ManualClock) -> ZoneHost { + let (facts, _script) = FactQueue::scripted(64); + ZoneHost::::new( + zone, + HostConfig { + boot_epoch: 7, + ..HostConfig::default() + }, + Arc::new(clock.clone()), + RoomConfig::for_zone(zone, DEFAULT_SEED).expect("room"), + ) + .with_facts(facts) + .with_transfer_issuer(|request| Some(format!("transfer:{}", request.target_zone))) +} + +fn join(host: &mut ZoneHost, who: &str) -> (ClientId, distributed_game::PlayerId) { + let claims = TicketClaims::new(who, host.zone_id(), u64::MAX, who, Role::Player).with_name(who); + let joined = host.join(&claims, 0, "").expect("join"); + let Viewer::Player(player) = joined.viewer else { + panic!("player") + }; + (joined.client, player) +} + +fn tick(host: &mut ZoneHost, clock: &ManualClock) -> Vec { + clock.advance(Duration::from_micros(33_334)); + host.advance(); + host.drain_outbound() +} + +fn messages(out: &[Outbound]) -> Vec { + out.iter() + .filter_map(|o| match o { + Outbound::Frame { frame, .. } => ServerMessage::decode(frame, &()).ok(), + _ => None, + }) + .collect() +} + +fn hellos(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, + }) + .filter(|e| matches!(e, PitEvent::Hello { .. })) + .collect() +} + +#[test] +fn a_room_greets_with_its_coordinates_and_hands_off_through_doorways() { + let clock = ManualClock::new(); + let mut room = host("pit.0.0", &clock); + let (client, player) = join(&mut room, "ana"); + let out = tick(&mut room, &clock); + let hello = hellos(&out); + assert!(matches!( + hello.first(), + Some(PitEvent::Hello { + room_x: 0, + room_y: 0, + restored: false, + .. + }) + )); + // Walk to the east doorway (beside the pillar on its axis) and push + // through it, no attack, until the room hands the player over. + let mut target = None; + for seq in 1..600u32 { + let me = room.zone().player_position(player).expect("in the room"); + let (dx, dy) = (21.0 - me.0, 2.0 - me.1); + let len = (dx * dx + dy * dy).sqrt().max(1e-3); + let east = PitInput { + mx: (dx / len * 127.0) as i8, + my: (dy / len * 127.0) as i8, + buttons: 0, + }; + room.push_input( + client, + seq, + seq, + &distributed_game::GameInput::to_payload(&east), + ); + let out = tick(&mut room, &clock); + target = messages(&out).into_iter().find_map(|m| match m { + ServerMessage::Handoff { + target_zone, + transfer_ticket, + } => Some((target_zone, transfer_ticket)), + _ => None, + }); + if target.is_some() { + break; + } + } + let (target, ticket) = target.expect("walked through the east doorway"); + assert_eq!(target, "pit.1.0"); + assert_eq!(ticket, "transfer:pit.1.0"); + assert!(room.zone().player_position(player).is_none(), "left"); +} + +#[test] +fn kills_emit_facts_and_hibernation_keeps_the_mobs() { + let clock = ManualClock::new(); + let mut room = host("pit.2.1", &clock); + let (client, player) = join(&mut room, "bo"); + let mobs = room.zone().stats().mobs_alive; + // Chase and swing until something dies. + let mut seq = 0; + while room.zone().stats().kills == 0 && seq < 3000 { + seq += 1; + let me = room.zone().player_position(player).unwrap_or((0.0, 0.0)); + let nearest = room + .zone() + .mob_positions() + .into_iter() + .filter(|m| m.3) + .min_by(|a, b| { + let d = |m: &(u32, f32, f32, bool)| (m.1 - me.0).powi(2) + (m.2 - me.1).powi(2); + d(a).total_cmp(&d(b)) + }) + .map(|m| (m.1, m.2)) + .unwrap_or((0.0, 0.0)); + let (dx, dy) = (nearest.0 - me.0, nearest.1 - me.1); + let len = (dx * dx + dy * dy).sqrt().max(1e-3); + room.push_input(client, seq, seq, &encode_bot_input([dx / len, dy / len])); + tick(&mut room, &clock); + } + let stats = room.zone().stats(); + assert!(stats.kills >= 1, "a mob died"); + assert!(stats.pending >= 1, "its fact is in flight"); + assert_eq!(stats.mobs_alive, mobs - stats.kills as usize); + + // Sleep, wake: the dead stay dead, the count survives. + let snapshot = room.hibernate_state().expect("snapshot"); + let mut woke = host("pit.2.1", &clock); + woke.restore(&snapshot, ""); + let restored = woke.zone().stats(); + assert!(restored.restored); + assert_eq!(restored.kills, stats.kills); + assert_eq!(restored.mobs_alive, stats.mobs_alive); + join(&mut woke, "bo"); + let out = tick(&mut woke, &clock); + assert!(matches!( + hellos(&out).first(), + Some(PitEvent::Hello { restored: true, room_kills, .. }) if *room_kills == stats.kills + )); +} diff --git a/examples/game/server/service/tests/projection.rs b/examples/game/server/service/tests/projection.rs index 3f56fbfc..af66f45e 100644 --- a/examples/game/server/service/tests/projection.rs +++ b/examples/game/server/service/tests/projection.rs @@ -110,7 +110,7 @@ async fn settled_kills_project_into_live_player_stats() { let routes = game_demos::common::relay::relay_routes(world.celld().secret.clone(), world.projection()); tokio::spawn(async move { axum::serve(listener, routes).await.unwrap() }); - let sink = world.fact_sink(); + let sink = game_demos::mobpit::store::kill_sink(world.celld()).unwrap(); // A kill settled before the bus is attached waits in the Queue (the // relay answers 503 and the consumer retries). sink.commit(&kill("k0", "dev-ana", 2, "ana")).await.unwrap(); diff --git a/examples/game/server/sim/src/mobpit.rs b/examples/game/server/sim/src/mobpit.rs index 36a2e0f3..9d4b3033 100644 --- a/examples/game/server/sim/src/mobpit.rs +++ b/examples/game/server/sim/src/mobpit.rs @@ -1,9 +1,19 @@ -//! Mob Pit's input frame and its client-predicted step (the Arena's -//! movement; combat is server-owned and never predicted). +//! Mob Pit's input frame, its client-predicted step (the Arena's +//! movement; combat is server-owned and never predicted) and its rooms. +//! +//! **Rooms.** Mob Pit is a near-infinite grid of rooms, room `(x, y)` being +//! zone `pit..` (canonical integers, |x|, |y| ≤ [`MAX_ROOM`]). Every +//! room is the Arena floor (same pillars, so the predicted step is the +//! Arena's) with a doorway in the middle of each wall leading to the +//! neighbour. What lives in a room — its biome, danger level and mobs — is +//! a pure function of `(seed, x, y)` ([`pit_room`], `#[game_pure]`) built on +//! the Endless Grid's seeded hashing and biomes ([`crate::grid`]); the page +//! runs the same code compiled to WebAssembly (minimap colours, floor). -use distributed_game::{predict, GameInput}; +use distributed_game::{game_pure, predict, 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. @@ -42,3 +52,232 @@ impl PitInput { pub fn pit_step(state: &MoveState, input: &PitInput, _context: &(), _dt: f32) -> MoveState { arena::step(*state, input.movement()) } + +/// Largest |room coordinate|. +pub const MAX_ROOM: i32 = 1_000_000; +/// Half width of a doorway (m), centred on each wall. +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; + +/// 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`). +const SLOTS: [(f32, f32); 12] = [ + (0.9659258, 0.258819), + ( + std::f32::consts::FRAC_1_SQRT_2, + std::f32::consts::FRAC_1_SQRT_2, + ), + (0.258819, 0.9659258), + (-0.258819, 0.9659258), + ( + -std::f32::consts::FRAC_1_SQRT_2, + std::f32::consts::FRAC_1_SQRT_2, + ), + (-0.9659258, 0.258819), + (-0.9659258, -0.258819), + ( + -std::f32::consts::FRAC_1_SQRT_2, + -std::f32::consts::FRAC_1_SQRT_2, + ), + (-0.258819, -0.9659258), + (0.258819, -0.9659258), + ( + std::f32::consts::FRAC_1_SQRT_2, + -std::f32::consts::FRAC_1_SQRT_2, + ), + (0.9659258, -0.258819), +]; + +/// Hash salt of the room contents. +const SALT_ROOM: u32 = 900; + +/// Which room ([`pit_room`]'s input). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, GameRecord)] +pub struct PitRoomContext { + /// World seed. + pub seed: u32, + /// Room x. + pub x: i32, + /// Room y. + pub y: i32, +} + +/// One mob slot of a room: its tier and home. +#[derive(Debug, Clone, Copy, PartialEq, GameRecord)] +pub struct RoomMob { + /// 1 grunt · 2 brute · 3 elite. + pub tier: u8, + /// Home x (m, room-local). + pub x: f32, + /// Home y (m, room-local). + pub y: f32, +} + +/// What a room holds. +#[derive(Debug, Clone, PartialEq, GameRecord)] +pub struct PitRoomLayout { + /// Biome index into [`grid::BIOMES`] (the room's look). + pub biome: u8, + /// Danger 1..=3: grows with the distance from the entrance room. + pub danger: u8, + /// Mob slots (ids 1..). + pub mobs: Vec, +} + +/// The contents of room `(x, y)`: biome from the Endless Grid's noise, +/// danger from the distance to `pit.0.0`, 6–12 mobs on two rings between +/// the pillars with tiers weighted by danger. Pure and deterministic. +#[game_pure(PitRoomFn)] +pub fn pit_room(room: &PitRoomContext) -> PitRoomLayout { + let at = (room.x, room.y); + 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 count = 6 + (h % 7) as usize; + let mut mobs = Vec::with_capacity(count); + for i in 0..count { + let roll = grid::hash32(h ^ (i as u32).wrapping_mul(0x9e37_79b9)); + let ring = if i % 2 == 0 { 11.5 } else { 16.0 }; + let (dx, dy) = SLOTS[i * SLOTS.len() / count]; + // Elites only where it is dangerous; brutes grow with danger. + let tier = match (roll % 100, danger) { + (r, 3) if r < 25 => 3, + (r, 2) if r < 8 => 3, + (r, d) if r < 15 + 20 * u32::from(d) => 2, + _ => 1, + }; + mobs.push(RoomMob { + tier, + x: ring * dx, + y: ring * dy, + }); + } + PitRoomLayout { + biome, + danger, + mobs, + } +} + +/// The rooms' world, for the page (generated `PIT_WORLD`). +#[derive(Debug, Clone, PartialEq, GameRecord)] +pub struct PitWorld { + /// Default seed. + pub default_seed: u32, + /// Floor half extent (m). + pub half: f32, + /// Doorway half width (m). + pub door_half: f32, + /// Largest |room coordinate|. + pub max_room: i32, + /// Biome names by index. + pub biomes: Vec, +} + +/// The `PIT_WORLD` constant. +pub fn pit_world() -> PitWorld { + PitWorld { + default_seed: DEFAULT_SEED, + half: arena::HALF, + door_half: DOOR_HALF, + max_room: MAX_ROOM, + biomes: grid::BIOMES.map(String::from).to_vec(), + } +} + +/// `pit..`. +pub fn room_zone(room: (i32, i32)) -> String { + format!("pit.{}.{}", room.0, room.1) +} + +/// Parse `pit..` (canonical integers within [`MAX_ROOM`]). +pub fn parse_room(zone_id: &str) -> Option<(i32, i32)> { + let rest = zone_id.strip_prefix("pit.")?; + let (x, y) = rest.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 doorway a player at `(x, y)` pushing with stick `(mx, my)` walks +/// through, as a room offset: against the wall, inside the doorway span, +/// pushing outward. +pub fn doorway(x: f32, y: f32, mx: i8, my: i8) -> Option<(i32, i32)> { + let edge = arena::HALF - arena::PLAYER_RADIUS - 0.05; + if y.abs() < DOOR_HALF { + if x >= edge && mx > 30 { + return Some((1, 0)); + } + if x <= -edge && mx < -30 { + return Some((-1, 0)); + } + } + if x.abs() < DOOR_HALF { + if y >= edge && my > 30 { + return Some((0, 1)); + } + if y <= -edge && my < -30 { + return Some((0, -1)); + } + } + None +} + +/// Where a player arrives through the doorway it came in by (`from` is the +/// offset of the room it left, e.g. `(-1, 0)` = came from the west), +/// keeping its position along the wall. +pub fn arrival(from: (i32, i32), along: f32) -> (f32, f32) { + let inside = arena::HALF - 2.0; + let along = along.clamp(-(DOOR_HALF - 0.6), DOOR_HALF - 0.6); + match from { + (-1, 0) => (-inside, along), + (1, 0) => (inside, along), + (0, -1) => (along, -inside), + _ => (along, inside), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rooms_are_deterministic_and_vary() { + let ctx = |x, y| PitRoomContext { + seed: DEFAULT_SEED, + x, + y, + }; + assert_eq!(pit_room(&ctx(3, -2)), pit_room(&ctx(3, -2))); + let rooms: Vec<_> = (-3..=3).map(|x| pit_room(&ctx(x, 0))).collect(); + assert!(rooms.iter().all(|r| (6..=MAX_MOBS).contains(&r.mobs.len()))); + assert!(rooms.windows(2).any(|w| w[0] != w[1])); + assert_eq!(pit_room(&ctx(0, 0)).danger, 1); + assert_eq!(pit_room(&ctx(9, 9)).danger, 3); + assert!(rooms + .iter() + .all(|r| (r.biome as usize) < grid::BIOMES.len())); + } + + #[test] + fn room_ids_and_doorways() { + assert_eq!(room_zone((1, -2)), "pit.1.-2"); + assert_eq!(parse_room("pit.1.-2"), Some((1, -2))); + assert_eq!(parse_room("pit.01.2"), None); + assert_eq!(parse_room("pit-1"), None); + assert_eq!(parse_room("pit.2000000.0"), None); + let edge = arena::HALF - arena::PLAYER_RADIUS; + assert_eq!(doorway(edge, 0.5, 127, 0), Some((1, 0))); + assert_eq!(doorway(edge, 0.5, -127, 0), None, "pushing back in"); + assert_eq!(doorway(edge, 5.0, 127, 0), None, "wall, not doorway"); + assert_eq!(doorway(0.0, -edge, 0, -127), Some((0, -1))); + assert_eq!(arrival((-1, 0), 9.0).0, -(arena::HALF - 2.0)); + assert!(arrival((-1, 0), 9.0).1 < DOOR_HALF); + } +} diff --git a/examples/game/server/src/common/mod.rs b/examples/game/server/src/common/mod.rs index 4ef46cb8..0d46c1b5 100644 --- a/examples/game/server/src/common/mod.rs +++ b/examples/game/server/src/common/mod.rs @@ -10,7 +10,7 @@ pub mod celld; pub mod dev; -mod guests; +pub mod guests; pub mod relay; pub mod zone_status; diff --git a/examples/game/server/src/common/zone_status.rs b/examples/game/server/src/common/zone_status.rs index a5f466f5..09fd0300 100644 --- a/examples/game/server/src/common/zone_status.rs +++ b/examples/game/server/src/common/zone_status.rs @@ -4,10 +4,12 @@ //! this host, under a bus consumer group of its own. //! //! This is the consumer of `zone.activated` / `zone.released` / -//! `zone.lease_expired` and `zone_state.saved` / `zone_state.deleted`; the -//! grid's world map reads it to tell active, hibernated and cold zones apart -//! without opening one cell per zone (a read of a never-used cell would -//! create it). Eventually consistent: for views only. +//! `zone.lease_expired` and `zone_state.saved` / `zone_state.deleted` (the +//! native multi-host demos' zones) and of `zone.activity_changed` (zones +//! hosted as celld objects: Mob Pit's rooms); the grid's world map and Mob +//! Pit's minimap read it to tell active, dormant, hibernated and cold zones +//! apart without opening one cell per zone (a read of a never-used cell +//! would create it). Eventually consistent: for views only. use std::collections::HashMap; @@ -18,8 +20,8 @@ use distributed::microsvc::{ }; use distributed::SqliteRepository; use distributed_game::world::zone_status::{ - projectors, read_model_catalog, ZoneLeases, ZoneSnapshots, ZoneStatus, APPLICATION, - ZONE_LEASES, ZONE_SNAPSHOTS, + activity_projector, projectors, read_model_catalog, ZoneActivities, ZoneLeases, ZoneSnapshots, + ZoneStatus, APPLICATION, ZONE_ACTIVITIES, ZONE_LEASES, ZONE_SNAPSHOTS, }; /// See the module docs. @@ -42,6 +44,13 @@ async fn project_snapshots( projection.apply(ZONE_SNAPSHOTS, &context).await } +async fn project_activities( + context: CausalProjectorContext, + projection: ModeledProjection, +) -> Result<(), HandlerError> { + projection.apply(ZONE_ACTIVITIES, &context).await +} + /// `{select} WHERE zone_id IN (?, ?, …)` over `zones`. fn by_zone(select: &str, zones: &[String]) -> sqlx::QueryBuilder { let mut query = sqlx::QueryBuilder::new(select); @@ -80,7 +89,9 @@ impl ZoneStatusView { .modeled_projector(leases) .handle(project_leases) .modeled_projector(snapshots) - .handle(project_snapshots), + .handle(project_snapshots) + .modeled_projector(activity_projector()) + .handle(project_activities), ) .with_bus( SqliteBus::new_with_group(bus.clone(), group.clone()) @@ -90,6 +101,45 @@ impl ZoneStatusView { Ok(Self { repo }) } + /// Every `zone_activities` row whose zone id starts with `prefix` + /// (zones hosted as celld objects). + pub async fn activities(&self, prefix: &str) -> Result, String> { + let rows = sqlx::query_as::<_, (String, String, String, bool, i64, String, String, i64, i64)>( + "SELECT zone_id, status, activation, restored, players, node, isolate, activations, \ + changed_at_ms FROM zone_activities WHERE zone_id LIKE ? ORDER BY zone_id", + ) + .bind(format!("{prefix}%")) + .fetch_all(self.repo.pool()) + .await + .map_err(|e| format!("zone activities: {e}"))?; + Ok(rows + .into_iter() + .map( + |( + zone_id, + status, + activation, + restored, + players, + node, + isolate, + activations, + changed_at_ms, + )| ZoneActivities { + zone_id, + status, + activation, + restored, + players, + node, + isolate, + activations, + changed_at_ms, + }, + ) + .collect()) + } + /// The status of each of `zones` (`Cold` when the read model has no /// row). pub async fn statuses(&self, zones: &[String]) -> Result, String> { diff --git a/examples/game/server/src/devcelld.rs b/examples/game/server/src/devcelld.rs index 56690444..636812f3 100644 --- a/examples/game/server/src/devcelld.rs +++ b/examples/game/server/src/devcelld.rs @@ -45,6 +45,8 @@ pub struct DevCelld { project: PathBuf, port: u16, secret: String, + ticket_secret: String, + vars: Vec<(String, String)>, relay_url: Option, child: Option, generation: u32, @@ -56,18 +58,33 @@ impl DevCelld { /// Queue consumer relays domain events (a demo server's /// `/internal/celld-queue/relay`); `None` keeps them queued. pub fn start(relay_url: Option) -> Result { + Self::start_with(relay_url, &[]) + } + + /// [`start`](Self::start) with extra worker vars (e.g. + /// `ZONE_HIBERNATE_AFTER_MS` for the rooms). + pub fn start_with(relay_url: Option, vars: &[(&str, &str)]) -> Result { let project = std::env::temp_dir().join(format!( "game-demo-celld-{}-{}", std::process::id(), crate::common::random_hex(8) )); - let mut celld = Self::in_project(&project, relay_url)?; + let mut celld = Self::in_project_with(&project, relay_url, vars)?; celld.keep = false; Ok(celld) } /// Start celld on `project` (created; existing state is reused). pub fn in_project(project: &Path, relay_url: Option) -> Result { + Self::in_project_with(project, relay_url, &[]) + } + + /// [`in_project`](Self::in_project) with extra worker vars. + pub fn in_project_with( + project: &Path, + relay_url: Option, + vars: &[(&str, &str)], + ) -> Result { let build = cells_dir().join("build"); if !build.join("worker/shim.mjs").exists() { return Err(format!( @@ -87,6 +104,11 @@ impl DevCelld { project: project.to_path_buf(), port: free_port(), secret: test_secret(), + ticket_secret: test_secret(), + vars: vars + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), relay_url, child: None, generation: 0, @@ -111,6 +133,16 @@ impl DevCelld { &self.secret } + /// The secret the rooms verify tickets with (`GAME_TICKET_SECRET`). + pub fn ticket_secret(&self) -> &str { + &self.ticket_secret + } + + /// `ws://127.0.0.1:{port}` (append `/rooms/{room}`). + pub fn ws_url(&self) -> String { + format!("ws://127.0.0.1:{}", self.port) + } + /// Project directory (holds `.celld/dev` and the logs). pub fn project(&self) -> &Path { &self.project @@ -124,7 +156,13 @@ impl DevCelld { .celld-version); point {CELLD_BIN_ENV} at the celld binary" ) })?; - let mut vars = format!("DISTRIBUTED_INTERNAL_SECRET={}\n", self.secret); + let mut vars = format!( + "DISTRIBUTED_INTERNAL_SECRET={}\nGAME_TICKET_SECRET={}\n", + self.secret, self.ticket_secret + ); + for (key, value) in &self.vars { + vars.push_str(&format!("{key}={value}\n")); + } if let Some(relay) = &self.relay_url { vars.push_str(&format!("CELLD_QUEUE_RELAY_URL={relay}\n")); } diff --git a/examples/game/server/src/grid/dev.rs b/examples/game/server/src/grid/dev.rs index a9202bca..8dda2e93 100644 --- a/examples/game/server/src/grid/dev.rs +++ b/examples/game/server/src/grid/dev.rs @@ -103,6 +103,7 @@ async fn stored_cells(state: &MapState, zones: &[String]) -> serde_json::Map { json!({ "state": "hibernated", "bytes": bytes }) } + ZoneStatus::Dormant => json!({ "state": "dormant" }), ZoneStatus::Cold => json!({ "state": "cold" }), }; out.insert(zone, fact); diff --git a/examples/game/server/src/mobpit/board.rs b/examples/game/server/src/mobpit/board.rs index 3ae71fe0..2bb8fc08 100644 --- a/examples/game/server/src/mobpit/board.rs +++ b/examples/game/server/src/mobpit/board.rs @@ -2,15 +2,18 @@ //! //! - Store: `InMemoryLeaderboard`, or `RedisLeaderboard` when `REDIS_URL` //! is set (sorted sets; every idempotent write is one Lua script). -//! - Live path (fire-and-forget): after a kill commits, the fact sink calls -//! [`Leaderboards::submit_kill`], which enqueues one occurrence on a -//! `BackgroundLeaderboard` and returns immediately. A full queue or a Redis -//! outage drops the update (counted); a rebuild heals it. -//! - Idempotency: every kill becomes a `DomainEventOccurrence` whose id is -//! derived from `(producer, "Crawler:{principal}", event sequence, -//! "mob_killed")` — the same id whether it comes from the live receipt, a -//! ledger replay of the same fact, or the cell's event history. The -//! projector's apply keys (`"{id}#{change}"`) make every repeat a no-op. +//! - Live path (fire-and-forget): kills commit in the Crawler cells, next to +//! the rooms on celld; their `crawler.mob_killed` events reach this server +//! through the outbox Queue relay and the crawler directory +//! (`super::directory`). Every change of a crawler's totals there becomes +//! one [`CrawlerTotals`] occurrence ([`Leaderboards::submit_totals`]) on a +//! `BackgroundLeaderboard`: `Max` of kills and XP, so repeats, replays and +//! reordering are no-ops. A full queue or a Redis outage drops the update +//! (counted); the next change or a rebuild heals it. +//! - Idempotency of the rebuild: every kill in a cell's history becomes a +//! `DomainEventOccurrence` whose id is derived from `(producer, +//! "Crawler:{principal}", event sequence, "mob_killed")`; the projector's +//! apply keys (`"{id}#{change}"`) make every repeat a no-op. //! - Rebuild ([`Leaderboards::rebuild`]): read the durable event history //! of every crawler in the directory (`super::directory`) from its cell, //! project it into the *staging* boards and atomically promote them. @@ -36,7 +39,7 @@ use distributed_game::GameRecord; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use super::crawler::{KillReceipt, AGGREGATE_TYPE}; +use super::crawler::AGGREGATE_TYPE; use super::store::CrawlerCells; pub use mobpit_crawler::{KILLS_BOARD, XP_BOARD}; @@ -175,7 +178,37 @@ pub fn kill_occurrence( .map_err(|e| format!("capture kill occurrence: {e:?}")) } -/// kills += 1, xp += xp. +/// Live projection input: a crawler's totals after a kill (the post-kill +/// state its cell published), monotonic in `kills`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, distributed::DomainEvent)] +#[domain_event(name = "mobpit.crawler_totals", version = 1)] +pub struct CrawlerTotals { + /// Principal. + pub crawler: String, + /// Mobs killed. + pub kills: u32, + /// Total XP. + pub xp: u64, +} + +/// The occurrence of `principal` reaching `kills` kills (one id per total). +pub fn totals_occurrence(totals: &CrawlerTotals) -> Result { + DomainEventOccurrence::capture_external( + ExternalEventSource { + producer: PRODUCER.into(), + stream: format!("{AGGREGATE_TYPE}:{}", totals.crawler), + position: u64::from(totals.kills), + key: "totals".into(), + }, + UNIX_EPOCH, + Default::default(), + totals, + ) + .map_err(|e| format!("capture totals occurrence: {e:?}")) +} + +/// Rebuild: kills += 1, xp += xp per recorded kill. Live: the greater of +/// the stored and the reported totals. pub struct KillProjection { kills: Board, xp: Board, @@ -193,12 +226,32 @@ impl KillProjection { impl LeaderboardProjection for KillProjection { fn handles(&self, event_name: &str) -> bool { event_name == ::DESCRIPTOR.name + || event_name == ::DESCRIPTOR.name } fn project( &self, event: &DomainEventOccurrence, ) -> Result, LeaderboardError> { + if event.descriptor().name == ::DESCRIPTOR.name { + let totals: CrawlerTotals = event + .decode_body() + .map_err(|e| LeaderboardError::invalid(format!("crawler_totals body: {e:?}")))?; + return Ok(vec![ + LeaderboardChange::new( + self.kills.clone(), + ScoreSubmission::new( + &totals.crawler, + f64::from(totals.kills), + ScorePolicy::Max, + ), + ), + LeaderboardChange::new( + self.xp.clone(), + ScoreSubmission::new(&totals.crawler, totals.xp as f64, ScorePolicy::Max), + ), + ]); + } let kill: MobKilled = event .decode_body() .map_err(|e| LeaderboardError::invalid(format!("mob_killed body: {e:?}")))?; @@ -311,20 +364,15 @@ impl Leaderboards { (&self.kills, &self.xp) } - /// Fire-and-forget live update for a committed (or replayed) kill. - pub fn submit_kill(&self, kill: &KillReceipt) { - self.remember_name(&kill.crawler, &kill.name); - match kill_occurrence( - &kill.crawler, - kill.sequence, - kill.at_ms, - &MobKilled { - crawler: kill.crawler.clone(), - mob: kill.mob.clone(), - tier: kill.tier, - xp: kill.xp, - }, - ) { + /// Fire-and-forget live update: `principal` (shown as `name`) now has + /// these totals. + pub fn submit_totals(&self, principal: &str, name: &str, kills: u32, xp: u64) { + self.remember_name(principal, name); + match totals_occurrence(&CrawlerTotals { + crawler: principal.to_string(), + kills, + xp, + }) { Ok(occurrence) => { self.background.submit(occurrence); } diff --git a/examples/game/server/src/mobpit/mod.rs b/examples/game/server/src/mobpit/mod.rs index 988b3d69..7e826d73 100644 --- a/examples/game/server/src/mobpit/mod.rs +++ b/examples/game/server/src/mobpit/mod.rs @@ -1,63 +1,66 @@ -//! **Mob Pit** (+ **Leaderboard**) — non-blocking durable facts, crash -//! recovery and authoritative combat; kills projected into sorted-set -//! leaderboards. +//! **Mob Pit** (+ **Leaderboard**) — a near-infinite grid of rooms joined by +//! doorways, authoritative melee combat, kills settled exactly once in the +//! players' durable cells, and live room status. //! -//! - [`zone`]: the 30 Hz zone (players, 12 mobs with a telegraphed attack -//! state machine, melee cones). A kill emits a fact and keeps ticking. -//! - [`crawler`]: the per-player `Crawler` aggregate and its one command; -//! XP is decided there, the principal comes from the trusted session. -//! - [`store`]: the zone server's side of the celld cells -//! (`examples/game/cells`): the `FactSink` (with the "slow storage" -//! latency knob) that routes facts to them via -//! `distributed_game::celld::CelldFactSink`, and the cell reader. -//! - [`directory`]: the crawler list, a projection of the cells' domain -//! events (outbox → Queue → `crate::common::relay` → bus). -//! - [`board`]: kills/XP leaderboards (in-memory or Redis), fire-and-forget -//! live updates, idempotent apply keys, rebuild from the cells' events. -//! - [`presence`]: who is online where, projected from the runtime's player -//! lifecycle facts (`HostConfig::lifecycle_facts`), ordered by fact stamp. -//! - [`routes`]: dev routes (`/dev/mobpit/*`). +//! The rooms run on **celld**: every room (`pit..`) is a Durable +//! Object of the cells worker (`examples/game/cells`, zone code in +//! `mobpit_rooms`, hosted by `distributed_game::celld_zone`). celld decides +//! which node runs a room; the game never does. A room's kill facts go to +//! the killer's Crawler cell object to object; its lifecycle (active / +//! dormant / hibernated) goes to its zone-activity cell. //! -//! `game-demos mobpit` and `game-demos leaderboard` start the same server -//! (one process hosts the pit and the boards); they differ only in the page -//! `run.sh` opens. Durable state is in celld (`--celld-url` / `CELLD_URL`, -//! secret `DISTRIBUTED_INTERNAL_SECRET`); `run.sh` and the tests start -//! `celld dev` with `examples/game/cells`. `--data-dir` / `MOBPIT_DATA_DIR` -//! keeps the persisted ticket secret (so guests keep their identity across -//! a restart, the crash-recovery demo) and the default projection bus. +//! This process (`game-demos mobpit|leaderboard`) is the services side, no +//! room runs here: +//! - [`routes`]: the dev routes — `/dev/ticket` mints room tickets for +//! guests (signed with the secret the rooms verify), `/dev/mobpit/*`. +//! - [`world`]: the projection bus the cells' outbox Queue relays onto +//! (`crate::common::relay`), the crawler directory ([`directory`]), the +//! rooms' status (`zone_status`, for the minimap) and the leaderboards +//! ([`board`]) fed from the directory. +//! - [`store`]: reads of the Crawler and presence cells. +//! +//! `--data-dir` / `MOBPIT_DATA_DIR` keeps the dev ticket secret (shared +//! with the rooms through celld's `GAME_TICKET_SECRET`; guests keep their +//! identity across restarts) and the default projection bus. pub mod board; pub mod crawler; pub mod directory; -pub mod presence; pub mod routes; pub mod store; pub mod world; pub mod zone; use std::future::Future; +use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; -use distributed_game::net::{GameServer, ServerConfig}; +use distributed_game::net::TicketKey; -use crate::common::{self, random_hex, Demo, DemoArgs, DemoOptions, Guests, RunningDemo}; +use crate::common::relay::relay_routes; +use crate::common::{random_hex, shutdown_signal, DemoArgs}; pub use world::{MobPitWorld, WorldOptions}; pub use zone::{encode_bot_input, game_surface, MobPit}; +/// Room ids this demo admits (`pit..`). +pub fn hosts_zone(zone_id: &str) -> bool { + mobpit_rooms::parse_room(zone_id).is_some() +} + /// `game-demos mobpit|leaderboard` options. #[derive(Debug, Clone, clap::Args)] pub struct MobPitArgs { - /// Shared demo options. + /// Shared demo options (`--secret` / `DISTRIBUTED_GAME_SECRET` is the + /// rooms' ticket secret, `GAME_TICKET_SECRET` on celld). #[command(flatten)] pub common: DemoArgs, - /// celld serving `examples/game/cells` (the Crawler and presence - /// cells). The internal secret comes from - /// `DISTRIBUTED_INTERNAL_SECRET`. + /// celld serving `examples/game/cells` (the rooms and their cells). + /// The internal secret comes from `DISTRIBUTED_INTERNAL_SECRET`. #[arg(long, env = "CELLD_URL")] pub celld_url: String, /// Directory of local dev files: the persisted dev ticket secret, the - /// default projection bus and the crawler directory's read models. + /// default projection bus and this server's read models. /// Default: `$TMPDIR/game-demos-mobpit`. #[arg(long, env = "MOBPIT_DATA_DIR")] pub data_dir: Option, @@ -67,58 +70,54 @@ pub struct MobPitArgs { /// Redis key prefix of the boards (`mobpit-service` reads the same). #[arg(long, env = "MOBPIT_REDIS_PREFIX", default_value = mobpit_crawler::BOARD_REDIS_PREFIX)] pub redis_prefix: String, - /// Start with this artificial storage latency (ms). - #[arg(long, env = "MOBPIT_STORAGE_LATENCY_MS", default_value_t = 0)] - pub storage_latency_ms: u64, - /// SQLite bus (`sqlite:/path/bus.db`) the Crawler cells' - /// `crawler.mob_killed` domain events are published to (relayed from - /// celld's outbox Queue); `mobpit-service` and this server's crawler - /// directory project them. Default: `sqlite:{data-dir}/bus.db`. + /// SQLite bus the cells' domain events are published to (relayed from + /// celld's outbox Queue); `mobpit-service` and this server project it. + /// Default: `sqlite:{data-dir}/bus.db`. #[arg(long, env = "MOBPIT_PROJECTION_BUS")] pub projection_bus: Option, } -/// Demo registration. -pub struct MobPitDemo; - -impl Demo for MobPitDemo { - type Zone = MobPit; - const NAME: &'static str = "mobpit"; - const DEFAULT_ZONE: &'static str = "pit-1"; - - fn hosts_zone(zone_id: &str) -> bool { - zone_id.starts_with("pit-") - } +/// The services running on a Tokio task. +pub struct RunningServices { + /// Bound address. + pub addr: SocketAddr, + /// Resolves when the server stopped. + pub task: tokio::task::JoinHandle>, +} - /// Plain `common::spawn` gets a throwaway world (temp dir, in-memory - /// board, no routes of its own) on the celld named by `CELLD_URL` and - /// `DISTRIBUTED_INTERNAL_SECRET`; use [`spawn`] / [`run`] instead. - fn server(config: ServerConfig, guests: Guests) -> GameServer { - let celld = store::CelldOptions::from_env().expect("Mob Pit needs celld"); - let world = MobPitWorld::open(WorldOptions::temporary(celld)) - .expect("mobpit world (needs a Tokio runtime)"); - world.server(config, guests).0 +impl RunningServices { + /// `http://addr`. + pub fn http_url(&self) -> String { + format!("http://{}", self.addr) } } -/// Start Mob Pit over `world` (tests; dev routes need `options.dev`). +/// Serve `world` on `listener` until `signal` (tests): the internal relay +/// route always, the dev routes when `dev`. pub fn spawn( listener: tokio::net::TcpListener, - options: DemoOptions, + key: TicketKey, + dev: bool, world: Arc, signal: impl Future + Send + 'static, -) -> std::io::Result> { - common::spawn_with::( - listener, - options, - move |config, guests| world.server(config, guests), - signal, - ) +) -> 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)); + } + let task = tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(signal) + .await + }); + Ok(RunningServices { addr, task }) } -/// Dev ticket secret kept in the data dir, so guest tokens (and therefore -/// principals) survive a server restart. -fn persisted_secret(dir: &std::path::Path) -> std::io::Result { +/// The dev ticket secret kept in the data dir (`dev-ticket-secret`), so +/// guests keep their identity across restarts; `run.sh` gives the same +/// file to celld as the rooms' `GAME_TICKET_SECRET`. +pub fn persisted_secret(dir: &std::path::Path) -> std::io::Result { let path = dir.join("dev-ticket-secret"); if let Ok(secret) = std::fs::read_to_string(&path) { let secret = secret.trim().to_string(); @@ -126,6 +125,7 @@ fn persisted_secret(dir: &std::path::Path) -> std::io::Result { return Ok(secret); } } + std::fs::create_dir_all(dir)?; let secret = random_hex(64); std::fs::write(&path, &secret)?; #[cfg(unix)] @@ -136,8 +136,8 @@ fn persisted_secret(dir: &std::path::Path) -> std::io::Result { Ok(secret) } -/// CLI entry: connect to the cells, rebuild the board from them, serve. -pub async fn run(mut args: MobPitArgs) -> std::io::Result<()> { +/// CLI entry: open the services, rebuild the board from the cells, serve. +pub async fn run(args: MobPitArgs) -> std::io::Result<()> { let data_dir = args .data_dir .clone() @@ -155,15 +155,10 @@ pub async fn run(mut args: MobPitArgs) -> std::io::Result<()> { let world = MobPitWorld::start(options) .await .map_err(std::io::Error::other)?; - world - .sink_state() - .set_latency(std::time::Duration::from_millis(args.storage_latency_ms)); - if args.common.secret.is_none() { - args.common.secret = Some(persisted_secret(&data_dir)?); - } - // Board = projection of the cells: rebuild it from the crawler - // directory before accepting players (the in-memory board starts - // empty; Redis may have missed updates). + let ticket_secret = match args.common.secret.clone() { + Some(secret) => secret, + None => persisted_secret(&data_dir)?, + }; match world.rebuild_board().await { Ok(report) => eprintln!( "mobpit: leaderboard rebuilt from cells: {}", @@ -171,16 +166,24 @@ pub async fn run(mut args: MobPitArgs) -> std::io::Result<()> { ), Err(error) => eprintln!("mobpit: leaderboard rebuild failed (serving anyway): {error}"), } - let bus = world.bus_url().to_string(); - eprintln!( - "mobpit: celld {} · projection bus {bus} · data dir {} · boot {} · leaderboard {}", + let listener = + tokio::net::TcpListener::bind((args.common.host.as_str(), args.common.port)).await?; + let running = spawn( + listener, + TicketKey::new(ticket_secret), + args.common.dev, + world.clone(), + shutdown_signal(), + )?; + // Parsed by run.sh and the client tests: keep the format. + println!("LISTENING http://{}", running.addr); + println!( + "demo mobpit · rooms on celld {} (ws …/rooms/{}) · projection bus {} · leaderboard {} · dev routes {}", args.celld_url, - data_dir.display(), - world.boot(), - world.board().backend() + mobpit_rooms::ENTRANCE, + world.bus_url(), + world.board().backend(), + if args.common.dev { "ON" } else { "off" } ); - common::run_with::(args.common, move |config, guests| { - world.server(config, guests) - }) - .await + running.task.await.map_err(std::io::Error::other)? } diff --git a/examples/game/server/src/mobpit/presence.rs b/examples/game/server/src/mobpit/presence.rs deleted file mode 100644 index 960cff9c..00000000 --- a/examples/game/server/src/mobpit/presence.rs +++ /dev/null @@ -1,167 +0,0 @@ -//! Presence read model projected from the runtime's player lifecycle facts -//! (`HostConfig::lifecycle_facts`, `distributed_game::lifecycle`). -//! -//! The fact sink hands every lifecycle fact to [`Presence::apply`] instead -//! of a Crawler cell. Facts commit in per-principal order within one zone, -//! but across zones (a handoff, a reconnect elsewhere) commit order says -//! nothing: each entry keeps the [`FactStamp`] of the fact that set it and a -//! fact that was *emitted* earlier (`FactStamp::emission_order`) is ignored, -//! so "left pit-1" committed after "joined pit-2" cannot mark a player -//! offline. A retried fact (same key) applies idempotently. - -use std::cmp::Ordering; -use std::collections::BTreeMap; -use std::sync::Mutex; - -use distributed_game::lifecycle::Lifecycle; -use distributed_game::FactStamp; -use serde::Serialize; - -/// Where a player is. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum PresenceStatus { - /// Connected to `zone`. - Online, - /// Connection lost; `zone` keeps the slot for the reconnect grace. - Away, - /// Left `zone` (or its grace expired). - Offline, -} - -/// One player's presence. -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PresenceEntry { - /// Verified principal (the fact's binding, never its payload). - pub principal: String, - /// Zone of the latest transition. - pub zone: String, - /// Status after it. - pub status: PresenceStatus, - /// Host wall time of that transition (Unix ms). - pub since_ms: u64, - #[serde(skip)] - stamp: FactStamp, -} - -/// The projection. -#[derive(Debug, Default)] -pub struct Presence { - entries: Mutex>, -} - -impl Presence { - /// Apply one lifecycle fact of `principal`. False when an entry set by - /// a later-emitted fact already exists (stale, ignored). - pub fn apply(&self, principal: &str, stamp: &FactStamp, event: &Lifecycle) -> bool { - let status = match event { - Lifecycle::Joined(_) | Lifecycle::Reconnected(_) => PresenceStatus::Online, - Lifecycle::ConnectionLost(_) => PresenceStatus::Away, - Lifecycle::Left(_) | Lifecycle::Disconnected(_) => PresenceStatus::Offline, - }; - let mut entries = self.entries.lock().expect("presence"); - if let Some(current) = entries.get(principal) { - if current.stamp.emission_order(stamp) != Ordering::Less { - return false; - } - } - entries.insert( - principal.to_string(), - PresenceEntry { - principal: principal.to_string(), - zone: event.zone().to_string(), - status, - since_ms: stamp.emitted_at_ms, - stamp: stamp.clone(), - }, - ); - true - } - - /// One player. - pub fn get(&self, principal: &str) -> Option { - self.entries - .lock() - .expect("presence") - .get(principal) - .cloned() - } - - /// Everyone seen, by principal. - pub fn snapshot(&self) -> Vec { - self.entries - .lock() - .expect("presence") - .values() - .cloned() - .collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use distributed_game::lifecycle::{JoinVia, LeftReason, PlayerJoined, PlayerLeft}; - - fn stamp(zone: &str, activation: u64, tick: u64, at: u64) -> FactStamp { - FactStamp { - zone_id: zone.into(), - tick, - emitted_at_ms: at, - activation, - seq: tick, - ..FactStamp::default() - } - } - - #[test] - fn a_handoff_leave_committed_late_does_not_mark_the_player_offline() { - let presence = Presence::default(); - let joined_b = Lifecycle::Joined(PlayerJoined { - zone: "pit-2".into(), - via: JoinVia::Handoff, - from_zone: Some("pit-1".into()), - }); - let left_a = Lifecycle::Left(PlayerLeft { - zone: "pit-1".into(), - reason: LeftReason::Handoff, - }); - // pit-2 commits first; pit-1's departure (emitted earlier) after. - assert!(presence.apply("ana", &stamp("pit-2", 4, 2, 1_001), &joined_b)); - assert!(!presence.apply("ana", &stamp("pit-1", 3, 90, 1_000), &left_a)); - let ana = presence.get("ana").unwrap(); - assert_eq!( - (ana.zone.as_str(), ana.status), - ("pit-2", PresenceStatus::Online) - ); - // Replaying the same fact is a no-op. - assert!(!presence.apply("ana", &stamp("pit-2", 4, 2, 1_001), &joined_b)); - } - - #[test] - fn a_drop_in_the_join_tick_and_millisecond_still_marks_the_player_away() { - use distributed_game::lifecycle::{ConnectionLost, LostCause}; - let presence = Presence::default(); - let joined = Lifecycle::Joined(PlayerJoined { - zone: "pit-1".into(), - via: JoinVia::Ticket, - from_zone: None, - }); - let lost = Lifecycle::ConnectionLost(ConnectionLost { - zone: "pit-1".into(), - grace_ms: 15_000, - cause: LostCause::Dropped, - }); - // Same zone, activation, tick and wall time: only the host's - // emission sequence orders them. - let at = |seq| FactStamp { - seq, - ..stamp("pit-1", 3, 0, 1_000) - }; - assert!(presence.apply("bo", &at(1), &joined)); - assert!(presence.apply("bo", &at(2), &lost)); - assert_eq!(presence.get("bo").unwrap().status, PresenceStatus::Away); - assert!(!presence.apply("bo", &at(1), &joined), "stale join ignored"); - } -} diff --git a/examples/game/server/src/mobpit/routes.rs b/examples/game/server/src/mobpit/routes.rs index 8beeab24..cb051d3d 100644 --- a/examples/game/server/src/mobpit/routes.rs +++ b/examples/game/server/src/mobpit/routes.rs @@ -1,35 +1,37 @@ -//! **Dev-only** Mob Pit routes (mounted with `--dev`, next to `/dev/ticket`). -//! None of them takes an identity or a reward from the caller. +//! **Dev-only** Mob Pit routes (mounted with `--dev`). None of them takes an +//! identity or a reward from the caller. //! -//! - `GET /dev/mobpit/storage`, `POST /dev/mobpit/storage {latencyMs}` — -//! artificial latency in front of every cell commit ("slow storage"). -//! - `GET /dev/mobpit/stats` — boot id, sink counters, pending facts, recent -//! commits (key, exact input, receipt). -//! - `GET /dev/mobpit/crawlers` — every crawler in the directory (the -//! `player_stats` projection of the cells' domain events; eventually -//! consistent). -//! - `GET /dev/mobpit/leaderboard[?member=principal]` — the pushed top-N -//! snapshot, plus rank / neighbours of `member` on both boards. +//! - `POST /dev/ticket` `{zone, name?, role?, guest?}` → a single-use room +//! ticket for a **server-generated** guest principal, signed with the +//! rooms' ticket secret (see `crate::common::dev` for the fields). Only +//! `pit..` rooms. A real game mints tickets from its sign-in (the +//! SvelteKit demo does). +//! - `GET /dev/mobpit/rooms` — every known room's status (`active` / +//! `dormant` / `hibernated`, players, restored, debug node), the +//! `zone_status` read model projected from the rooms' activity cells. +//! - `GET /dev/mobpit/stats` — boot id, celld, board backend. +//! - `GET /dev/mobpit/crawlers` — every crawler in the directory. +//! - `GET /dev/mobpit/leaderboard[?member=principal]` — the top-N snapshot, +//! plus rank / neighbours of `member` on both boards. //! - `POST /dev/mobpit/leaderboard/rebuild` — rebuild both boards from the -//! event history of every crawler in the directory (read from its cell); -//! reports whether the result matches the cells. -//! - `GET /dev/mobpit/presence` — who is online / away / offline where, -//! projected from the runtime's player lifecycle facts. -//! -//! The always-mounted internal relay route is `crate::common::relay`. +//! event history of every crawler in the directory. +//! - `GET /dev/mobpit/presence?principal=` — the player's presence cell. use std::collections::HashMap; use std::sync::Arc; -use std::time::Duration; +use std::time::{SystemTime, UNIX_EPOCH}; use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::routing::{get, post}; use axum::{Json, Router}; -use serde::Deserialize; +use distributed_game::net::TicketKey; +use distributed_game::{Role, TicketClaims}; use serde_json::{json, Value}; use super::world::MobPitWorld; +use crate::common::dev::{TicketRequest, TicketResponse, TICKET_TTL_SECS}; +use crate::common::guests; type ApiResult = Result, (StatusCode, Json)>; @@ -40,79 +42,130 @@ fn internal(error: impl std::fmt::Display) -> (StatusCode, Json) { ) } +fn bad_request(message: &str) -> (StatusCode, Json) { + (StatusCode::BAD_REQUEST, Json(json!({ "error": message }))) +} + +#[derive(Clone)] +struct Dev { + world: Arc, + key: TicketKey, +} + /// The router. -pub fn routes(world: Arc) -> Router { +pub fn routes(world: Arc, key: TicketKey) -> Router { Router::new() - .route("/dev/mobpit/storage", get(get_storage).post(set_storage)) + .route("/dev/ticket", post(ticket)) + .route("/dev/mobpit/rooms", get(rooms)) .route("/dev/mobpit/stats", get(stats)) .route("/dev/mobpit/crawlers", get(crawlers)) .route("/dev/mobpit/leaderboard", get(leaderboard)) .route("/dev/mobpit/leaderboard/rebuild", post(rebuild)) .route("/dev/mobpit/presence", get(presence)) - .with_state(world) -} - -/// `POST /dev/mobpit/storage` body. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct StorageBody { - latency_ms: u64, -} - -fn storage_json(world: &MobPitWorld) -> Value { - json!({ "latencyMs": world.sink_state().latency().as_millis() as u64 }) + .with_state(Dev { world, key }) } -async fn get_storage(State(world): State>) -> Json { - Json(storage_json(&world)) +async fn ticket( + State(dev): State, + Json(request): Json, +) -> Result, (StatusCode, Json)> { + if !super::hosts_zone(&request.zone) { + return Err(bad_request("not a Mob Pit room (pit..)")); + } + 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(&dev.key, token)) + .unwrap_or_else(guests::new_principal); + let name = request + .name + .as_deref() + .map(guests::sanitize_name) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| format!("Guest {}", principal.get(6..10).unwrap_or(""))); + let expires_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) + + TICKET_TTL_SECS; + let ticket = dev.key.sign( + &TicketClaims::new( + principal.clone(), + request.zone.clone(), + expires_at, + guests::random_hex(24), + role, + ) + .with_name(name.clone()), + ); + Ok(Json(TicketResponse { + ticket, + guest: guests::guest_token(&dev.key, &principal), + principal, + name, + zone: request.zone, + expires_at, + })) } -async fn set_storage( - State(world): State>, - Json(body): Json, -) -> Json { - world - .sink_state() - .set_latency(Duration::from_millis(body.latency_ms)); - Json(storage_json(&world)) +async fn rooms(State(dev): State) -> ApiResult { + Ok(Json( + json!({ "rooms": dev.world.rooms().await.map_err(internal)? }), + )) } -async fn stats(State(world): State>) -> Json { +async fn stats(State(dev): State) -> Json { + let world = &dev.world; Json(json!({ "boot": world.boot(), "dataDir": world.data_dir().display().to_string(), "celld": world.cells().url(), - "latencyMs": world.sink_state().latency().as_millis() as u64, - "pending": world.zone_pending(), - "sink": world.sink_state().stats(), - "recent": world.sink_state().recent(), "board": world.board().backend(), })) } -async fn crawlers(State(world): State>) -> ApiResult { +async fn crawlers(State(dev): State) -> ApiResult { Ok(Json( - json!({ "crawlers": world.crawlers().await.map_err(internal)? }), + json!({ "crawlers": dev.world.crawlers().await.map_err(internal)? }), )) } async fn leaderboard( - State(world): State>, + State(dev): State, Query(query): Query>, ) -> ApiResult { - let snapshot = (*world.board().snapshot()).clone(); + let board = dev.world.board(); + let snapshot = (*board.snapshot()).clone(); let me = match query.get("member").filter(|m| !m.is_empty()) { - Some(member) => world.board().around(member, 2).await.map_err(internal)?, + Some(member) => board.around(member, 2).await.map_err(internal)?, None => Value::Null, }; Ok(Json(json!({ "snapshot": snapshot, "me": me }))) } -async fn presence(State(world): State>) -> Json { - Json(json!({ "presence": world.presence().snapshot() })) +async fn rebuild(State(dev): State) -> ApiResult { + let report = dev.world.rebuild_board().await.map_err(internal)?; + Ok(Json(serde_json::to_value(report).map_err(internal)?)) } -async fn rebuild(State(world): State>) -> ApiResult { - let report = world.rebuild_board().await.map_err(internal)?; - Ok(Json(serde_json::to_value(report).map_err(internal)?)) +async fn presence( + State(dev): State, + Query(query): Query>, +) -> ApiResult { + let principal = query + .get("principal") + .filter(|p| !p.is_empty()) + .ok_or_else(|| bad_request("principal is required"))?; + let current = dev + .world + .cells() + .presence(principal) + .await + .map_err(internal)?; + Ok(Json(json!({ "principal": principal, "presence": current }))) } diff --git a/examples/game/server/src/mobpit/store.rs b/examples/game/server/src/mobpit/store.rs index 156bcfc8..28a5d66c 100644 --- a/examples/game/server/src/mobpit/store.rs +++ b/examples/game/server/src/mobpit/store.rs @@ -1,46 +1,38 @@ -//! Mob Pit's durable state lives in celld cells (`examples/game/cells`); -//! this module is the zone server's side of them. +//! Mob Pit's durable state lives in celld cells (`examples/game/cells`), +//! next to the rooms that feed them: a room's kill and lifecycle facts go +//! to the killer's `crawler/{principal}` and `presence/{principal}` cells +//! object to object (`mobpit_rooms`, `distributed_game::celld_zone`). //! -//! - [`MobPitFactSink`] is the game's `FactSink`: optional artificial -//! latency (the "slow storage" toggle), then -//! `distributed_game::celld::CelldFactSink` (the command fact sink over a -//! remote-only celld command host) sends the fact over the internal path -//! to its cell — `crawler.record_kill` to `crawler/{principal}` -//! (`FactRoute::by_principal`), the runtime's -//! player lifecycle facts to `presence/{principal}` (`lifecycle_route`) — -//! then a fire-and-forget leaderboard update. The command id comes from -//! the fact key, so a retry replays through the cell's command ledger -//! instead of crediting twice; celld slow or down keeps facts in the -//! zone's queue (`RetryPolicy::until_settled`), never in a local store. -//! - Each kill commit also writes the `crawler.mob_killed` domain event to -//! the cell's outbox in the same cell transaction. celld drains it to the -//! `game-demo-outbox` Queue; the worker's consumer relays every message to -//! this server's authenticated `/internal/celld-queue/relay` -//! (`crate::common::relay`), which publishes it onto the projection bus. -//! `mobpit-service` and this server's [`super::directory`] project it -//! (the crawler list). [`CrawlerCells`] reads one cell by principal -//! (profiles, the board rebuild's kill histories). +//! Each kill commit writes the `crawler.mob_killed` domain event to the +//! cell's outbox in the same cell transaction. celld drains it to the +//! `game-demo-outbox` Queue; the worker's consumer relays every message to +//! this server's authenticated `/internal/celld-queue/relay` +//! (`crate::common::relay`), which publishes it onto the projection bus. +//! `mobpit-service` and this server's [`super::directory`] project it (the +//! crawler list, which feeds the leaderboard). [`CrawlerCells`] reads one +//! cell by principal (the board rebuild's kill histories, presence). -use std::collections::VecDeque; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; use std::time::Duration; use distributed::cell_host::{InternalHttpSecret, CELL_INTERNAL_SECRET_HEADER}; -use distributed_game::celld::{CelldFactSink, FactRoute}; -use distributed_game::lifecycle::Lifecycle; -use distributed_game::{BoxFuture, Fact, FactError, FactReceipt, FactSink}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use super::board::Leaderboards; -use super::crawler::{KillReceipt, RecordKillInput, CRAWLER_KIND, PRESENCE_KIND, RECORD_KILL}; -use super::presence::Presence; +use super::crawler::{RecordKillInput, CRAWLER_KIND, PRESENCE_KIND}; +use distributed_game::celld::{CelldFactSink, FactRoute}; -/// Command-ledger service id for game facts. -pub const SERVICE_ID: &str = "mobpit"; -/// Principal for facts without a player (none are emitted today). -pub const SYSTEM_PRINCIPAL: &str = "mobpit-zone"; +/// Kill facts into the Crawler cells over celld's internal HTTP path, the +/// route a room uses object to object (tests and tools; rooms send theirs +/// from inside celld). Command-ledger service `mobpit-tools`. +pub fn kill_sink(celld: &CelldOptions) -> Result { + Ok(CelldFactSink::celld( + celld.url.clone(), + celld.secret.clone(), + "mobpit-tools", + "mobpit-tools", + )? + .route(FactRoute::by_principal::(CRAWLER_KIND))) +} pub use crate::common::CelldOptions; @@ -158,192 +150,3 @@ fn encode_segment(segment: &str) -> String { } encoded } - -/// A committed kill as the dev routes report it. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CommittedFact { - /// Fact idempotency key. - pub key: String, - /// Command input as committed (needed to replay the exact command). - pub input: serde_json::Value, - /// Receipt. - pub receipt: KillReceipt, - /// True when the ledger replayed an earlier commit. - pub replayed: bool, -} - -/// Counters of the fact sink. -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SinkStats { - /// Commit attempts started. - pub attempts: u64, - /// First-time commits. - pub committed: u64, - /// Ledger replays (same key seen again). - pub replayed: u64, - /// Failed attempts (retryable or permanent). - pub errors: u64, - /// Facts currently inside `commit` (incl. the artificial latency). - pub in_sink: u64, - /// Lifecycle facts settled in presence cells (not counted above). - pub presence: u64, -} - -/// Shared sink state (lives in the world). -#[derive(Default)] -pub struct SinkState { - latency_ms: AtomicU64, - stats: Mutex, - recent: Mutex>, -} - -impl SinkState { - /// Artificial storage latency. - pub fn latency(&self) -> Duration { - Duration::from_millis(self.latency_ms.load(Ordering::Relaxed)) - } - - /// Set the artificial storage latency (clamped to 5 s). - pub fn set_latency(&self, latency: Duration) -> Duration { - let ms = latency.as_millis().min(5_000) as u64; - self.latency_ms.store(ms, Ordering::Relaxed); - Duration::from_millis(ms) - } - - /// Counter snapshot. - pub fn stats(&self) -> SinkStats { - self.stats.lock().expect("stats").clone() - } - - /// Most recent commits, newest last (at most 64). - pub fn recent(&self) -> Vec { - self.recent - .lock() - .expect("recent") - .iter() - .cloned() - .collect() - } - - fn update(&self, f: impl FnOnce(&mut SinkStats)) { - f(&mut self.stats.lock().expect("stats")); - } -} - -/// The game's fact sink (cloned into every zone's fact worker). -#[derive(Clone)] -pub struct MobPitFactSink { - celld: CelldFactSink, - state: Arc, - board: Arc, - presence: Arc, -} - -impl MobPitFactSink { - /// Sink into the cells at `celld`, reporting into `state`, feeding - /// `board`, mirroring settled lifecycle facts into `presence`. - pub fn new( - celld: &CelldOptions, - state: Arc, - board: Arc, - presence: Arc, - ) -> Result { - let sink = CelldFactSink::celld( - celld.url.clone(), - celld.secret.clone(), - SERVICE_ID, - SYSTEM_PRINCIPAL, - )? - .route(FactRoute::by_principal::(CRAWLER_KIND)) - .lifecycle_route(PRESENCE_KIND); - Ok(Self { - celld: sink, - state, - board, - presence, - }) - } - - async fn commit_kill(&self, fact: &Fact) -> Result { - let latency = self.state.latency(); - if !latency.is_zero() { - tokio::time::sleep(latency).await; - } - if fact.principal().is_none() { - return Err(FactError::Permanent( - "kill facts need a player principal".into(), - )); - } - let receipt = self.celld.commit(fact).await?; - if fact.command() == RECORD_KILL { - if let Ok(kill) = serde_json::from_value::(receipt.payload.clone()) { - // Fire-and-forget: never awaits the leaderboard store. - self.board.submit_kill(&kill); - let mut recent = self.state.recent.lock().expect("recent"); - if recent.len() == 64 { - recent.pop_front(); - } - recent.push_back(CommittedFact { - key: fact.key().to_string(), - input: fact.input().clone(), - receipt: kill, - replayed: receipt.replayed, - }); - } - } - Ok(receipt) - } -} - -impl FactSink for MobPitFactSink { - fn commit<'a>(&'a self, fact: &'a Fact) -> BoxFuture<'a, Result> { - Box::pin(async move { - // Runtime lifecycle facts go to the player's presence cell, not - // a Crawler cell (and not the kill counters); the settled ones - // are mirrored into the in-process presence view. - if let (Some(event), Some(principal)) = (Lifecycle::of(fact), fact.principal()) { - let receipt = self.celld.commit(fact).await?; - self.presence.apply(principal, fact.stamp(), &event); - self.state.update(|s| s.presence += 1); - return Ok(receipt); - } - self.state.update(|s| { - s.attempts += 1; - s.in_sink += 1; - }); - let result = self.commit_kill(fact).await; - self.state.update(|s| { - s.in_sink -= 1; - match &result { - Ok(r) if r.replayed => s.replayed += 1, - Ok(_) => s.committed += 1, - Err(_) => s.errors += 1, - } - }); - result - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn shards_cannot_add_path_segments() { - assert_eq!(encode_segment("guest-0a1b"), "guest-0a1b"); - assert_eq!(encode_segment("odd:name"), "odd:name"); - assert_eq!(encode_segment("a/b c"), "a%2Fb%20c"); - } - - #[test] - fn options_require_http_and_a_strong_secret() { - assert!(CelldOptions::new("ftp://x", "test-only-secret-0123456789abcdef").is_err()); - assert!(CelldOptions::new("http://127.0.0.1:1", "short").is_err()); - let ok = - CelldOptions::new("http://127.0.0.1:1/", "test-only-secret-0123456789abcdef").unwrap(); - assert_eq!(ok.url, "http://127.0.0.1:1"); - } -} diff --git a/examples/game/server/src/mobpit/world.rs b/examples/game/server/src/mobpit/world.rs index c56f1678..61e91318 100644 --- a/examples/game/server/src/mobpit/world.rs +++ b/examples/game/server/src/mobpit/world.rs @@ -1,29 +1,26 @@ -//! Process-wide Mob Pit state shared by the zones, the fact worker and the -//! dev routes: the celld cells (fact sink and reader), the sink's -//! latency/counters, the leaderboards, the projection bus and the crawler -//! directory projected from it. +//! Mob Pit's services next to the rooms: the celld cells reader, the +//! projection bus the outbox Queue relays onto, the crawler directory and +//! the rooms' zone status projected from it, and the leaderboards fed by +//! the directory. The rooms themselves run on celld (`mobpit_rooms`); this +//! process never hosts one. +use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::{Arc, OnceLock}; use std::time::Duration; -use distributed_game::net::{GameServer, ServerConfig, ZoneFactory}; -use distributed_game::RetryPolicy; use serde_json::{json, Value}; use super::board::Leaderboards; use super::directory::CrawlerDirectory; -use super::presence::Presence; -use super::store::{CelldOptions, CrawlerCells, MobPitFactSink, SinkState}; -use super::zone::{MobPit, MobPitConfig}; -use super::{routes, MobPitDemo}; -use crate::common::relay::{relay_routes, ProjectionBus, ProjectionPublisher}; -use crate::common::{random_hex, Demo, DemoRoutes, Guests}; +use super::store::{CelldOptions, CrawlerCells}; +use crate::common::random_hex; +use crate::common::relay::{ProjectionBus, ProjectionPublisher}; use distributed::leaderboard::LeaderboardChangePublisher; +use crate::common::zone_status::ZoneStatusView; -/// Unsettled facts one zone may hold before `emit_fact` reports backpressure. -pub const FACT_CAPACITY: usize = 256; +/// Bus consumer group of the rooms' zone status projection here. +pub const ZONE_STATUS_CONSUMER: &str = "mobpit-rooms"; /// How to open the world. #[derive(Clone)] @@ -31,7 +28,7 @@ pub struct WorldOptions { /// Directory of local dev files (the persisted ticket secret, the /// default projection bus). Durable game state is in celld. pub data_dir: PathBuf, - /// celld serving `examples/game/cells`. + /// celld serving `examples/game/cells` (the rooms and their cells). pub celld: CelldOptions, /// `redis://…` for a Redis leaderboard; None = in memory. pub redis_url: Option, @@ -41,7 +38,8 @@ pub struct WorldOptions { /// with `mobpit-service`). Default: `sqlite:{data_dir}/bus.db`. pub projection_bus: Option, /// SQLite database of this server's read models (the crawler - /// directory). Default: `sqlite:{data_dir}/read-models.db`. + /// directory, the rooms' status). Default: + /// `sqlite:{data_dir}/read-models.db`. pub read_models: Option, } @@ -82,31 +80,24 @@ impl WorldOptions { } } -/// A loaded profile: principal and `(xp, level, kills)` (None = no cell yet). -pub type LoadedProfile = (String, Option<(u64, u32, u32)>); - /// See the module docs. pub struct MobPitWorld { boot: String, data_dir: PathBuf, celld: CelldOptions, cells: Arc, - sink: Arc, - fact_sink: MobPitFactSink, board: Arc, - zone_pending: AtomicUsize, - profiles: Arc>>, projection: Arc>, directory: OnceLock, - presence: Arc, + rooms: OnceLock, bus_url: String, read_models_url: String, } impl MobPitWorld { - /// Open the world over celld and attach its projection bus and crawler - /// directory ([`Self::attach_projection`]). Does not rebuild the board: - /// call [`Self::rebuild_board`]. + /// Open the world over celld and attach its projection bus, crawler + /// directory and room status ([`Self::attach_projection`]). Does not + /// rebuild the board: call [`Self::rebuild_board`]. pub async fn start(options: WorldOptions) -> Result, String> { let world = Self::open(options)?; world.attach_projection().await?; @@ -114,9 +105,8 @@ impl MobPitWorld { } /// Open the world over celld without its projection (the relay answers - /// 503 and the crawler list is unavailable until - /// [`Self::attach_projection`]). Must be called inside a Tokio runtime. - /// Does not contact celld (cells are reached on first use). + /// 503 until [`Self::attach_projection`]). Must be called inside a + /// Tokio runtime. Does not contact celld. pub fn open(options: WorldOptions) -> Result, String> { std::fs::create_dir_all(&options.data_dir) .map_err(|e| format!("create {}: {e}", options.data_dir.display()))?; @@ -129,36 +119,21 @@ impl MobPitWorld { &options.redis_prefix, Some(notices.listener()), )?; - let sink = Arc::new(SinkState::default()); - let presence = Arc::new(Presence::default()); - let fact_sink = MobPitFactSink::new( - &options.celld, - sink.clone(), - board.clone(), - presence.clone(), - )?; - let bus_url = options.bus_url(); - let read_models_url = options.read_models_url(); Ok(Arc::new(Self { - bus_url, - read_models_url, + bus_url: options.bus_url(), + read_models_url: options.read_models_url(), directory: OnceLock::new(), + rooms: OnceLock::new(), boot: random_hex(8), data_dir: options.data_dir, cells: Arc::new(CrawlerCells::new(&options.celld)?), celld: options.celld, - sink, - fact_sink, board, - zone_pending: AtomicUsize::new(0), - profiles: Arc::default(), projection, - presence, })) } - /// Random id of this server process (shown by clients to spot restarts; - /// fact keys use `Ctx::fact_key` / the boot epoch). + /// Random id of this server process. pub fn boot(&self) -> &str { &self.boot } @@ -168,7 +143,7 @@ impl MobPitWorld { &self.data_dir } - /// Where the cells are. + /// Where the rooms and cells are. pub fn celld(&self) -> &CelldOptions { &self.celld } @@ -178,39 +153,35 @@ impl MobPitWorld { &self.cells } - /// Sink latency and counters. - pub fn sink_state(&self) -> &Arc { - &self.sink - } - /// Leaderboards. pub fn board(&self) -> &Arc { &self.board } - /// Presence mirrored from the lifecycle facts settled in presence cells. - pub fn presence(&self) -> &Arc { - &self.presence - } - - /// The game's fact sink. - pub fn fact_sink(&self) -> MobPitFactSink { - self.fact_sink.clone() - } - /// Open the projection bus the outbox Queue relay publishes onto /// (`/internal/celld-queue/relay`; 503 until this runs, so the Queue - /// keeps the messages) and start projecting it into the crawler - /// directory. Call once. - pub async fn attach_projection(&self) -> Result<(), String> { + /// keeps the messages), project it into the crawler directory and the + /// rooms' status, and feed the board from the directory. Call once. + pub async fn attach_projection(self: &Arc) -> Result<(), String> { let bus = ProjectionBus::open(&self.bus_url).await?; let directory = CrawlerDirectory::open(&self.read_models_url, bus.pool().clone()).await?; + let rooms = ZoneStatusView::open( + &self.read_models_url, + bus.pool().clone(), + ZONE_STATUS_CONSUMER, + ) + .await?; self.directory .set(directory) .map_err(|_| "projection already attached".to_string())?; + self.rooms + .set(rooms) + .map_err(|_| "projection already attached".to_string())?; self.projection .set(bus) - .map_err(|_| "projection already attached".to_string()) + .map_err(|_| "projection already attached".to_string())?; + tokio::spawn(feed_board(Arc::downgrade(self))); + Ok(()) } /// The attached projection bus, if any. @@ -235,43 +206,6 @@ impl MobPitWorld { .ok_or_else(|| "no projection attached: the crawler list is unavailable".into()) } - pub(crate) fn set_zone_pending(&self, n: usize) { - self.zone_pending.store(n, Ordering::Relaxed); - } - - /// Facts emitted by the zone and not yet settled (incl. backpressure backlog). - pub fn zone_pending(&self) -> usize { - self.zone_pending.load(Ordering::Relaxed) - } - - /// Load `principal`'s cell on a background task; the zone picks the - /// result up with [`Self::take_loaded_profiles`] on a later tick. - pub fn request_profile(&self, principal: &str) { - let cells = self.cells.clone(); - let profiles = self.profiles.clone(); - let principal = principal.to_string(); - let Ok(handle) = tokio::runtime::Handle::try_current() else { - return; - }; - handle.spawn(async move { - let state = match cells.crawler(&principal).await { - Ok(view) => view.state, - Err(error) => { - eprintln!("mobpit: load profile {principal}: {error}"); - None - } - }; - profiles - .lock() - .expect("profiles") - .push((principal, state.map(|c| (c.xp, c.level, c.kills)))); - }); - } - - pub(crate) fn take_loaded_profiles(&self) -> Vec { - std::mem::take(&mut *self.profiles.lock().expect("profiles")) - } - /// Rebuild the leaderboards from the event history of every crawler in /// the directory (read from each one's cell). pub async fn rebuild_board(&self) -> Result { @@ -302,40 +236,59 @@ impl MobPitWorld { .collect()) } - /// Game server for `config` plus the Mob Pit dev and internal routes. - pub fn server( - self: &Arc, - config: ServerConfig, - guests: Guests, - ) -> (GameServer, DemoRoutes) { - let world = self.clone(); - let mut config = config; - // Joined / connection lost / reconnected / disconnected / left facts - // for the presence cells. - config.host.lifecycle_facts = true; - let server = GameServer::new( - config, - ZoneFactory::sync(move |activation| { - let zone_id = activation.zone_id.as_str(); - MobPitDemo::hosts_zone(zone_id).then(|| MobPitConfig { - guests: guests.clone(), - world: world.clone(), - }) - }), - ) - // celld down or slow: facts stay in the zone's queue until they - // settle (backpressure at capacity), never in a local store. - .with_fact_sink( - self.fact_sink(), - FACT_CAPACITY, - RetryPolicy::until_settled(Duration::from_millis(50), Duration::from_secs(2)), - ); - ( - server, - DemoRoutes { - dev: routes::routes(self.clone()), - internal: relay_routes(self.celld.secret.clone(), self.projection.clone()), - }, - ) + /// Every room the read model knows (`zone_activities` rows of + /// `pit.*`): its status (`active` / `dormant` / `hibernated`), players, + /// whether the activation restored a snapshot, and — a debug detail — + /// the celld node running it. + pub async fn rooms(&self) -> Result, String> { + let view = self + .rooms + .get() + .ok_or("no projection attached: room status is unavailable")?; + Ok(view + .activities("pit.") + .await? + .into_iter() + .filter_map(|row| { + let (x, y) = mobpit_rooms::parse_room(&row.zone_id)?; + Some(json!({ + "zone": row.zone_id, "x": x, "y": y, "status": row.status, + "players": row.players, "restored": row.restored, + "activations": row.activations, "node": row.node, + "changedAtMs": row.changed_at_ms, + })) + }) + .collect()) + } +} + +/// Keep the boards in step with the crawler directory: every change of a +/// crawler's totals becomes one idempotent `Max` update. +async fn feed_board(world: std::sync::Weak) { + let mut seen: HashMap = HashMap::new(); + loop { + tokio::time::sleep(Duration::from_millis(250)).await; + let Some(world) = world.upgrade() else { + return; + }; + let Ok(directory) = world.directory() else { + continue; + }; + let Ok(rows) = directory.list().await else { + continue; + }; + for row in rows { + let totals = (row.kills, row.xp); + if seen.get(&row.player_id) == Some(&totals) { + continue; + } + world.board.submit_totals( + &row.player_id, + &row.name, + u32::try_from(row.kills).unwrap_or(0), + u64::try_from(row.xp).unwrap_or(0), + ); + seen.insert(row.player_id, totals); + } } } diff --git a/examples/game/server/src/mobpit/zone.rs b/examples/game/server/src/mobpit/zone.rs index 7946f5e8..6577fd77 100644 --- a/examples/game/server/src/mobpit/zone.rs +++ b/examples/game/server/src/mobpit/zone.rs @@ -1,1371 +1,5 @@ -//! The Mob Pit zone: authoritative melee combat at 30 Hz. -//! -//! Movement reuses the Arena's step ([`crate::arena::movement`], same floor -//! and pillars) through [`pit_step`], the declared predicted step the client -//! runs as WebAssembly; combat is server-only. Entities: -//! -//! | kind | ids | fields | -//! |---|---|---| -//! | 1 `player` | 1000 + player | x y vx vy dash cd (predicted) · name hp face swing level dead | -//! | 2 `mob` | 1..=12 | x y face tier hp state tele | -//! | 3 `pillar` | 100 + i | x y r (global) | -//! | 4 `pit` | 900 | pending slowMs tickMs tickMaxMs workUs committed backlog (global) | -//! -//! Mob state machine (deterministic, zone RNG only for wander targets): -//! `idle` (wander near home) → `chase` (living player within aggro radius or -//! hit by one) → `windup` (in reach: stand still, telegraph ring for 0.8 s) → -//! damage everyone inside the ring → `recover` (0.6 s) → `chase`; beyond the -//! leash or target dead → `return` home → `idle`. Dead mobs respawn after 4 s -//! with a new generation. -//! -//! The zone is the aggregate; players, mobs and pillars are its entities -//! (each with an [`EntityId`]), and the kinds are `#[derive(Replicated)]` -//! views *projected* from them (`of = Player`, `#[from]` fields copied, the -//! rest derived in `impl Project<..>`): a mob's HP becomes a percent, its -//! windup timer the `tele` ring, a dead mob no view at all. The zone -//! registers its collections once ([`Zone::entities`]) and projects its own -//! HUD numbers ([`PitView`]); there is no hand-written `replicate()`. -//! Events are the typed [`PitEvent`], input the `#[derive(GameInput)]` -//! [`PitInput`]; the browser client is generated from [`game_surface`]. -//! -//! A kill emits a typed fact **without awaiting storage**: the cell -//! command's own input, [`RecordKillInput`] `{mob, tier, name}`, keyed by -//! `ctx.fact_with_key(&input, &[m{mob}, generation])` and emitted with -//! `ctx.emit_fact_for(killer)` — the host binds the killer's verified -//! principal and the handler decides the XP. The key is -//! `crawler.record_kill:{zone}:{boot_epoch}:m{mob}:{generation}`: the boot -//! epoch grows across restarts, so keys never collide with an earlier run's -//! facts (generations and ticks restart at 0). The settlement arrives in -//! [`Zone::on_fact_settled`] on a later tick and is pushed to the killer as -//! [`PitEvent::Saved`] + [`PitEvent::Profile`]. +//! The Mob Pit room zone lives in `mobpit_rooms` (portable: the cells +//! worker hosts each room as a celld Durable Object). Re-exported here for +//! the client surface (`run.sh gen`), the native tests and the bots. -use std::collections::{BTreeMap, HashMap, VecDeque}; -use std::sync::Arc; -use std::time::Instant; - -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 super::board::BoardSnapshot; -use super::crawler::{level_floor, KillReceipt, RecordKillInput}; -use super::world::MobPitWorld; -use crate::arena::movement::{self, MoveState, PILLARS, PLAYER_RADIUS}; -use crate::arena::{Pillar, PillarView}; -use crate::common::Guests; - -/// 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 -/// state (replaced, never predicted, on the client). Projected from the -/// zone's `Player` entity: the move state is flattened, HP and level are -/// clamped, and its principal, credits and timers stay on the server. -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "player", id = 1, priority = 2.0, of = Player)] -pub struct PlayerView { - /// Position x. - #[field_type(movement::POS)] - #[correct(ease = 150)] - #[from(state.x)] - pub x: f32, - /// Position y. - #[field_type(movement::POS)] - #[correct(ease = 150)] - #[from(state.y)] - pub y: f32, - /// Velocity x. - #[field_type(movement::VEL)] - #[correct(snap)] - #[from(state.vx)] - pub vx: f32, - /// Velocity y. - #[field_type(movement::VEL)] - #[correct(snap)] - #[from(state.vy)] - pub vy: f32, - /// Remaining dash frames. - #[correct(snap)] - #[from(state.dash)] - pub dash: u8, - /// Remaining dash cooldown frames. - #[correct(snap)] - #[from(state.cd)] - pub cd: u8, - /// Display name. - #[priority(0.2)] - #[correct(replace)] - #[from] - pub name: String, - /// Hit points. - #[correct(replace)] - pub hp: u8, - /// Facing (rad). - #[angle(bits = 8)] - #[correct(replace)] - #[from] - pub face: f32, - /// Swing animation frames left. - #[correct(replace)] - pub swing: u8, - /// Level (from the durable cell). - #[correct(replace)] - pub level: u16, - /// Waiting to respawn. - #[correct(replace)] - pub dead: bool, -} - -/// A mob (dead mobs are not replicated); its home, target, generation and -/// wander point stay on the server. -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "mob", id = 2, of = Mob)] -pub struct MobView { - /// Position x. - #[quant(min = -movement::HALF, max = movement::HALF, bits = 14)] - #[from] - pub x: f32, - /// Position y. - #[quant(min = -movement::HALF, max = movement::HALF, bits = 14)] - #[from] - pub y: f32, - /// Facing (rad). - #[angle(bits = 8)] - #[from] - pub face: f32, - /// 1 grunt · 2 brute · 3 elite. - #[from] - pub tier: u8, - /// HP percent. - pub hp: u8, - /// 0 idle · 1 chase · 2 windup · 3 recover · 4 return. - pub state: u8, - /// Telegraph progress 0..=255 during a windup. - pub tele: u8, -} - -/// Zone numbers for the HUD (global), projected from the zone itself. -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "pit", id = 4, global, of = MobPit)] -pub struct PitView { - /// Facts emitted and not settled. - pub pending: u16, - /// Artificial storage latency (ms). - pub slow_ms: u16, - /// Tick interval EMA (ms). - pub tick_ms: f32, - /// Longest tick interval of the last 30 ticks (ms). - pub tick_max_ms: f32, - /// Tick work EMA (µs). - pub work_us: u32, - /// Settlements received. - pub committed: u32, - /// Facts waiting for backpressure to clear. - pub backlog: u16, -} - -/// A player's durable progress (from the cell on join, after each settlement). -#[derive(Debug, Clone, PartialEq, GameRecord)] -pub struct Profile { - /// Total XP. - pub xp: u64, - /// Level. - pub level: u32, - /// Kills. - pub kills: u32, - /// First XP of the level. - pub level_xp: u64, - /// First XP of the next level. - pub next_level_xp: u64, - /// `"cell"` (loaded on join) or `"settled"` (after a kill committed). - pub source: String, -} - -/// Mob Pit's events. -#[derive(Debug, Clone, PartialEq, GameEvent)] -pub enum PitEvent { - /// To a joining player. - #[game_event(id = 1)] - Hello { - /// Own entity id. - entity: u32, - /// Floor half extent (m). - half: f32, - /// Process boot id (changes on restart). - boot: String, - /// Melee reach (m). - attack_range: f32, - /// Cosine of the half cone angle. - attack_arc_cos: f32, - }, - /// Durable progress. - #[game_event(id = 2)] - Profile(Profile), - /// Broadcast the tick a mob dies (the fact is only queued). - #[game_event(id = 3)] - Kill { - /// Fact key. - key: String, - /// Mob entity id. - mob: u32, - /// Mob tier. - tier: u8, - /// Where it died. - x: f32, - /// Where it died. - y: f32, - /// Killer entity id. - killer: u32, - /// Killer display name. - killer_name: String, - }, - /// To the killer when its fact settled. - #[game_event(id = 4)] - Saved { - /// Fact key. - key: String, - /// Committed. - ok: bool, - /// The cell recognised the key (replayed receipt). - replayed: bool, - /// XP credited by this kill (decided by the cell). - xp: u32, - /// Total XP after it. - total_xp: u64, - /// Kills after it. - kills: u32, - /// Level after it. - level: u32, - /// Time from emit to settlement (ms). - waited_ms: u32, - /// Where the mob died. - x: f32, - /// Where the mob died. - y: f32, - /// Failure, if any. - error: Option, - /// Commit attempts (failures). - attempts: u32, - }, - /// Leaderboard snapshot (on join and whenever it changes). - #[game_event(id = 5)] - Board(BoardSnapshot), - /// To a player that died. - #[game_event(id = 6)] - Died { - /// Respawn delay (ms). - respawn_ms: u32, - }, -} - -/// Mob Pit's client surface (also used by the leaderboard page). -pub fn game_surface() -> GameSurface { - GameSurface::for_zone::("mobpit") - .events::() - .predict::() - .constant("pit_rules", &pit_rules()) -} - -pub use game_sim::mobpit::{pit_step, PitInput, PitStep}; - -/// Input bit: attack (bit 0 is the Arena's dash). -pub const BUTTON_ATTACK: u8 = PitInput::ATTACK; -/// Melee reach (m, from the player's centre to the mob's edge). -pub const ATTACK_RANGE: f32 = 2.2; -/// Cosine of the half cone angle (60°). -pub const ATTACK_ARC_COS: f32 = 0.5; -/// Damage per swing. -pub const ATTACK_DAMAGE: i32 = 10; -/// Swing cooldown (ticks). -pub const SWING_COOLDOWN: u8 = 13; -/// Swing animation length (ticks). -const SWING_FRAMES: u8 = 6; -/// Player max HP. -pub const PLAYER_HP: i32 = 100; -/// Number of mobs. -pub const MOBS: usize = 12; -const MAX_CREDITS: u8 = 4; -const VIEW_RADIUS: f32 = 40.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; -const PLAYER_RESPAWN_TICKS: u64 = 90; - -/// Mob tier stats: (max hp, speed m/s, damage, radius, telegraph radius). -fn tier_stats(tier: u8) -> (i32, f32, i32, f32, f32) { - match tier { - 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]; -/// Names of the `mob` kind's `state` values, by value. -pub const MOB_STATES: [&str; 6] = ["idle", "chase", "windup", "recover", "return", "dead"]; - -/// What the page draws of a mob tier (from [`tier_stats`]). -#[derive(Debug, Clone, Copy, PartialEq, GameRecord)] -pub struct MobTier { - /// Tier (the `mob` kind's `tier`). - pub tier: u8, - /// Body radius (m). - pub radius: f32, - /// Telegraph radius (m). - pub tele: f32, -} - -/// Mob Pit rules the client draws with (generated `PIT_RULES`). -#[derive(Debug, Clone, PartialEq, GameRecord)] -pub struct PitRules { - /// Every tier. - pub tiers: Vec, - /// `state` names by value. - pub states: Vec, - /// Melee reach (m, from the player's centre to the mob's edge). - pub attack_range: f32, - /// Half angle of the swing cone (radians). - pub attack_half_angle: f32, - /// Swing animation length (ticks; the player's `swing` counts down from it). - pub swing_frames: u8, -} - -/// The `PIT_RULES` constant. -pub fn pit_rules() -> PitRules { - PitRules { - tiers: TIERS - .iter() - .map(|&tier| { - let (_, _, _, radius, tele) = tier_stats(tier); - MobTier { tier, radius, tele } - }) - .collect(), - states: MOB_STATES.map(String::from).to_vec(), - attack_range: ATTACK_RANGE, - attack_half_angle: ATTACK_ARC_COS.acos(), - swing_frames: SWING_FRAMES, - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MobState { - Idle = 0, - Chase = 1, - Windup = 2, - Recover = 3, - Return = 4, - Dead = 5, -} - -struct Mob { - id: EntityId, - tier: u8, - home: (f32, f32), - x: f32, - y: f32, - face: f32, - hp: i32, - state: MobState, - timer: u16, - target: Option, - generation: u32, - respawn_at: u64, - wander: (f32, f32), -} - -struct Player { - id: EntityId, - principal: String, - name: String, - state: MoveState, - credits: u8, - hp: i32, - face: f32, - swing_cd: u8, - swing_anim: u8, - dead_until: Option, - level: u32, - regen: u8, -} - -struct PendingKill { - principal: String, - x: f32, - y: f32, - emitted: Instant, -} - -/// Zone construction parameters. -#[derive(Clone)] -pub struct MobPitConfig { - /// Display names from the dev ticket endpoint. - pub guests: Guests, - /// Durable cells, fact sink state and leaderboards. - pub world: Arc, -} - -/// The Mob Pit zone. -pub struct MobPit { - guests: Guests, - world: Arc, - players: BTreeMap, - mobs: Vec, - pillars: Vec, - pending: HashMap, - /// Facts refused by backpressure, retried each tick (oldest first). - backlog: VecDeque<(PlayerId, Fact, f32, f32)>, - board_version: u64, - last_tick: Option, - tick_ms: f32, - tick_max_ms: f32, - tick_max_window: f32, - window_ticks: u32, - work_us: f32, - committed: u32, -} - -/// Mob homes on two rings between the pillars, tiers by index. -fn mob_layout() -> Vec<(u8, (f32, f32))> { - let mut out = Vec::with_capacity(MOBS); - for i in 0..MOBS { - let ring = if i % 2 == 0 { 11.5 } else { 16.0 }; - let a = (i as f32 + 0.5) / MOBS as f32 * std::f32::consts::TAU; - let tier = match i { - 0 => 3, - 3 | 6 | 9 | 11 => 2, - _ => 1, - }; - out.push((tier, (ring * a.cos(), ring * a.sin()))); - } - out -} - -fn spawn_point(player: PlayerId) -> (f32, f32) { - const SPOTS: [(f32, f32); 6] = [ - (-4.5, -4.5), - (4.5, -4.5), - (4.5, 4.5), - (-4.5, 4.5), - (0.0, -5.5), - (0.0, 5.5), - ]; - 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 { - let (ox, oy) = (*x - px, *y - py); - let d2 = ox * ox + oy * oy; - let min = radius + pr; - if d2 < min * min { - let d = d2.sqrt().max(1e-4); - *x = px + ox / d * min; - *y = py + oy / d * min; - } - } - let lim = movement::HALF - radius; - *x = x.clamp(-lim, lim); - *y = y.clamp(-lim, lim); -} - -fn step_toward(x: &mut f32, y: &mut f32, tx: f32, ty: f32, stride: f32) -> f32 { - let (dx, dy) = (tx - *x, ty - *y); - let d = (dx * dx + dy * dy).sqrt(); - if d > 1e-4 { - let s = stride.min(d); - *x += dx / d * s; - *y += dy / d * s; - } - dy.atan2(dx) -} - -fn dist(a: (f32, f32), b: (f32, f32)) -> f32 { - ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() -} - -impl MobPit { - fn profile_event(xp: u64, level: u32, kills: u32, source: &str) -> PitEvent { - PitEvent::Profile(Profile { - xp, - level, - kills, - level_xp: level_floor(level), - next_level_xp: level_floor(level + 1), - source: source.into(), - }) - } - - fn player_by_principal(&self, principal: &str) -> Option { - self.players - .iter() - .find(|(_, p)| p.principal == principal) - .map(|(id, _)| *id) - } - - fn emit_kill(&mut self, ctx: &mut Ctx<'_, Self>, killer: PlayerId, mob: usize) { - let m = &self.mobs[mob]; - let (x, y, tier, id, generation) = (m.x, m.y, m.tier, m.id.get(), m.generation); - let Some(player) = self.players.get(&killer) else { - return; - }; - // The cell command's own input: what died, never who or how much XP. - let input = RecordKillInput { - mob: format!("{}/m{id}", ctx.zone_id()), - tier, - name: player.name.clone(), - }; - let Ok(fact) = ctx.fact_with_key(&input, &[&format_args!("m{id}"), &generation]) else { - return; - }; - let key = fact.key().to_string(); - ctx.broadcast(&PitEvent::Kill { - key, - mob: id, - tier, - x, - y, - killer: player.id.get(), - killer_name: player.name.clone(), - }); - self.backlog.push_back((killer, fact, x, y)); - self.flush_backlog(ctx); - } - - /// Hand queued facts to the host; never blocks. Backpressure keeps the - /// rest for the next tick. - fn flush_backlog(&mut self, ctx: &mut Ctx<'_, Self>) { - while let Some((player, fact, x, y)) = self.backlog.pop_front() { - let key = fact.key().to_string(); - match ctx.emit_fact_for(player, fact.clone()) { - Ok(()) => { - let principal = self - .players - .get(&player) - .map(|p| p.principal.clone()) - .unwrap_or_default(); - self.pending.insert( - key, - PendingKill { - principal, - x, - y, - emitted: Instant::now(), - }, - ); - } - Err(EmitError::Backpressure) => { - self.backlog.push_front((player, fact, x, y)); - return; - } - // The killer left before the fact could be queued, or the key - // is already in flight: nothing durable to do. - Err(_) => {} - } - } - } - - fn step_players(&mut self, ctx: &mut Ctx<'_, Self>) -> Vec<(PlayerId, f32, f32, f32)> { - let tick = ctx.tick(); - let mut swings = Vec::new(); - 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.swing_anim = p.swing_anim.saturating_sub(1); - if let Some(until) = p.dead_until { - // Dead: inputs are consumed but ignored. - let _ = ctx.take_inputs(*id, ConsumePolicy::Drain); - if tick >= until { - let (x, y) = spawn_point(*id); - p.state = MoveState { - x, - y, - ..MoveState::default() - }; - p.hp = PLAYER_HP; - p.dead_until = None; - } - continue; - } - while p.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. - p.state = pit_step(&p.state, &input, &(), 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 { - p.face = sy.atan2(sx); - } - if input.attack() && p.swing_cd == 0 { - p.swing_cd = SWING_COOLDOWN; - p.swing_anim = SWING_FRAMES; - swings.push((*id, p.state.x, p.state.y, p.face)); - } - } - p.regen = p.regen.wrapping_add(1); - if p.regen % 15 == 0 && p.hp < PLAYER_HP { - p.hp += 1; - } - ctx.set_view(*id, View::new(p.state.x, p.state.y, VIEW_RADIUS)); - } - swings - } - - fn resolve_swings(&mut self, ctx: &mut Ctx<'_, Self>, swings: Vec<(PlayerId, f32, f32, f32)>) { - for (player, x, y, face) in swings { - let (fx, fy) = (face.cos(), face.sin()); - for i in 0..self.mobs.len() { - let m = &mut self.mobs[i]; - if m.state == MobState::Dead { - continue; - } - let (_, _, _, radius, _) = tier_stats(m.tier); - let (dx, dy) = (m.x - x, m.y - y); - let d = (dx * dx + dy * dy).sqrt(); - if d > ATTACK_RANGE + radius { - continue; - } - let cos = if d < 1e-3 { - 1.0 - } else { - (dx * fx + dy * fy) / d - }; - 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); - } - } - } - } - - fn step_mobs(&mut self, ctx: &mut Ctx<'_, Self>) { - let dt = ctx.dt_secs(); - let tick = ctx.tick(); - let living: Vec<(PlayerId, f32, f32)> = self - .players - .iter() - .filter(|(_, p)| p.dead_until.is_none()) - .map(|(id, p)| (*id, p.state.x, p.state.y)) - .collect(); - let mut hits: Vec<(PlayerId, i32)> = Vec::new(); - for i in 0..self.mobs.len() { - let (max_hp, speed, damage, radius, tele) = tier_stats(self.mobs[i].tier); - 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]; - match m.state { - MobState::Dead => { - if tick >= m.respawn_at { - m.generation += 1; - m.hp = max_hp; - (m.x, m.y) = m.home; - m.state = MobState::Idle; - } - continue; - } - MobState::Idle => { - let nearest = living - .iter() - .map(|(id, x, y)| (*id, dist((m.x, m.y), (*x, *y)))) - .filter(|(_, d)| *d < AGGRO) - .min_by(|a, b| a.1.total_cmp(&b.1)); - if let Some((id, _)) = nearest { - m.state = MobState::Chase; - m.target = Some(id); - } else if dist((m.x, m.y), m.wander) < 0.3 { - let a = ctx.rng().next_f32() * std::f32::consts::TAU; - let r = ctx.rng().next_f32() * 2.5; - let m = &mut self.mobs[i]; - m.wander = (m.home.0 + r * a.cos(), m.home.1 + r * a.sin()); - } else { - let (wx, wy) = m.wander; - m.face = step_toward(&mut m.x, &mut m.y, wx, wy, 1.0 * dt); - } - } - MobState::Chase => match target_pos(m.target) { - Some((tx, ty)) if dist(m.home, (m.x, m.y)) < LEASH => { - let reach = radius + PLAYER_RADIUS + 0.6; - if dist((m.x, m.y), (tx, ty)) <= reach { - m.state = MobState::Windup; - 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.state = MobState::Return; - m.target = None; - } - }, - MobState::Windup => { - 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 { - hits.push((*id, damage)); - } - } - m.state = MobState::Recover; - m.timer = RECOVER_TICKS; - } - } - MobState::Recover => { - m.timer = m.timer.saturating_sub(1); - if m.timer == 0 { - m.state = MobState::Chase; - } - } - 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 dist((m.x, m.y), m.home) < 0.3 { - m.state = MobState::Idle; - m.wander = m.home; - } - } - } - let m = &mut self.mobs[i]; - push_out_of_pillars(&mut m.x, &mut m.y, radius); - } - // Light separation so a pack does not stack on one spot. - for i in 0..self.mobs.len() { - for j in (i + 1)..self.mobs.len() { - let (a, b) = (&self.mobs[i], &self.mobs[j]); - if a.state == MobState::Dead || b.state == MobState::Dead { - continue; - } - let min = tier_stats(a.tier).3 + tier_stats(b.tier).3; - let (dx, dy) = (b.x - a.x, b.y - a.y); - let d = (dx * dx + dy * dy).sqrt(); - if d < min && d > 1e-4 { - let push = (min - d) * 0.5; - let (nx, ny) = (dx / d * push, dy / d * push); - self.mobs[i].x -= nx; - self.mobs[i].y -= ny; - self.mobs[j].x += nx; - self.mobs[j].y += ny; - } - } - } - for (id, damage) in hits { - let Some(p) = self.players.get_mut(&id) else { - continue; - }; - if p.dead_until.is_some() { - continue; - } - p.hp -= damage; - if p.hp <= 0 { - p.hp = 0; - p.state.vx = 0.0; - p.state.vy = 0.0; - p.dead_until = Some(tick + PLAYER_RESPAWN_TICKS); - for m in &mut self.mobs { - if m.target == Some(id) { - m.target = None; - } - } - ctx.send( - id, - &PitEvent::Died { - respawn_ms: (PLAYER_RESPAWN_TICKS * 1000 / 30) as u32, - }, - ); - } - } - } - - fn deliver_profiles(&mut self, ctx: &mut Ctx<'_, Self>) { - for (principal, crawler) in self.world.take_loaded_profiles() { - let Some(id) = self.player_by_principal(&principal) else { - continue; - }; - let (xp, level, kills) = crawler.unwrap_or((0, 1, 0)); - if let Some(p) = self.players.get_mut(&id) { - p.level = level; - } - ctx.send(id, &Self::profile_event(xp, level, kills, "cell")); - } - } - - fn push_board(&mut self, ctx: &mut Ctx<'_, Self>) { - let version = self.world.board().version(); - if version != self.board_version { - self.board_version = version; - let snapshot = self.world.board().snapshot(); - ctx.broadcast(&PitEvent::Board((*snapshot).clone())); - } - } - - fn measure_tick(&mut self) { - let now = Instant::now(); - if let Some(last) = self.last_tick { - let ms = now.duration_since(last).as_secs_f32() * 1000.0; - self.tick_ms = if self.tick_ms == 0.0 { - ms - } else { - self.tick_ms * 0.9 + ms * 0.1 - }; - self.tick_max_window = self.tick_max_window.max(ms); - self.window_ticks += 1; - if self.window_ticks >= 30 { - self.tick_max_ms = self.tick_max_window; - self.tick_max_window = 0.0; - self.window_ticks = 0; - } - } - self.last_tick = Some(now); - } -} - -impl Zone for MobPit { - type Config = MobPitConfig; - type Input = PitInput; - type Timer = (); - type Message = distributed_game::NoMessages; - - fn schema() -> EntitySchema { - schema![PlayerView, MobView, PillarView, PitView] - } - - fn entities() -> Entities { - Entities::new() - .each(|z: &Self| &z.players) - .each(|z: &Self| &z.mobs) - .each(|z: &Self| &z.pillars) - .one(|z: &Self| z) - } - - fn on_create(_ctx: &mut Ctx<'_, Self>, config: MobPitConfig) -> Self { - let mobs = mob_layout() - .into_iter() - .enumerate() - .map(|(i, (tier, home))| Mob { - id: EntityId(i as u32 + 1), - tier, - home, - x: home.0, - y: home.1, - face: 0.0, - hp: tier_stats(tier).0, - state: MobState::Idle, - timer: 0, - target: None, - generation: 0, - respawn_at: 0, - wander: home, - }) - .collect(); - Self { - guests: config.guests, - world: config.world, - players: BTreeMap::new(), - mobs, - pillars: Pillar::all(), - pending: HashMap::new(), - backlog: VecDeque::new(), - board_version: u64::MAX, - last_tick: None, - tick_ms: 0.0, - tick_max_ms: 0.0, - tick_max_window: 0.0, - window_ticks: 0, - work_us: 0.0, - committed: 0, - } - } - - fn on_join(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId) { - let (principal, ticket_name) = ctx - .player(player) - .map(|p| (p.principal.clone(), p.name.clone())) - .unwrap_or_default(); - // A dev guest's registered name, else the name the ticket issuer - // vouched for (e.g. a SvelteKit session), else the principal. - let name = self - .guests - .name(&principal) - .or(ticket_name) - .unwrap_or_else(|| principal.clone()); - let (x, y) = spawn_point(player); - let entity = EntityId(1000 + player.0); - self.players.insert( - player, - Player { - id: entity, - principal: principal.clone(), - name, - state: MoveState { - x, - y, - ..MoveState::default() - }, - credits: MAX_CREDITS, - hp: PLAYER_HP, - face: 0.0, - swing_cd: 0, - swing_anim: 0, - dead_until: None, - level: 1, - regen: 0, - }, - ); - ctx.set_view(player, View::new(x, y, VIEW_RADIUS)); - ctx.send( - player, - &PitEvent::Hello { - entity: entity.get(), - half: movement::HALF, - boot: self.world.boot().to_string(), - attack_range: ATTACK_RANGE, - attack_arc_cos: ATTACK_ARC_COS, - }, - ); - ctx.send( - player, - &PitEvent::Board((*self.world.board().snapshot()).clone()), - ); - // Read path: load the durable cell off the tick; delivered later. - self.world.request_profile(&principal); - } - - fn on_leave(&mut self, _ctx: &mut Ctx<'_, Self>, player: PlayerId, _reason: LeaveReason) { - self.players.remove(&player); - for m in &mut self.mobs { - if m.target == Some(player) { - m.target = None; - } - } - } - - fn on_observe(&mut self, ctx: &mut Ctx<'_, Self>, observer: ObserverId) { - ctx.set_view(observer, View::new(0.0, 0.0, VIEW_RADIUS)); - ctx.send( - observer, - &PitEvent::Board((*self.world.board().snapshot()).clone()), - ); - } - - fn decode_input(&self, _player: PlayerId, payload: &[u8]) -> Option { - PitInput::from_payload(payload) - } - - fn on_tick(&mut self, ctx: &mut Ctx<'_, Self>) { - self.measure_tick(); - let work = Instant::now(); - self.deliver_profiles(ctx); - let swings = self.step_players(ctx); - self.resolve_swings(ctx, swings); - self.step_mobs(ctx); - self.flush_backlog(ctx); - self.push_board(ctx); - self.world - .set_zone_pending(ctx.facts_in_flight() + self.backlog.len()); - let us = work.elapsed().as_secs_f32() * 1e6; - self.work_us = if self.work_us == 0.0 { - us - } else { - self.work_us * 0.9 + us * 0.1 - }; - } - - fn on_fact_settled(&mut self, ctx: &mut Ctx<'_, Self>, settlement: FactSettlement) { - // The host's own lifecycle facts (presence) are not kills. - if distributed_game::lifecycle::LIFECYCLE_COMMANDS.contains(&settlement.command.as_str()) { - return; - } - let pending = self.pending.remove(&settlement.key); - let (principal, x, y, waited_ms) = match &pending { - Some(p) => ( - p.principal.clone(), - p.x, - p.y, - p.emitted.elapsed().as_millis().min(u128::from(u32::MAX)) as u32, - ), - None => (String::new(), 0.0, 0.0, 0), - }; - let player = self.player_by_principal(&principal); - match settlement.outcome { - FactOutcome::Committed(receipt) => { - self.committed += 1; - let Ok(kill) = receipt.payload_as::() else { - return; - }; - let Some(player) = player else { return }; - if let Some(p) = self.players.get_mut(&player) { - p.level = kill.level; - } - ctx.send( - player, - &PitEvent::Saved { - key: settlement.key, - ok: true, - replayed: receipt.replayed, - xp: kill.xp, - total_xp: kill.total_xp, - kills: kill.kills, - level: kill.level, - waited_ms, - x, - y, - error: None, - attempts: 0, - }, - ); - ctx.send( - player, - &Self::profile_event(kill.total_xp, kill.level, kill.kills, "settled"), - ); - } - FactOutcome::Failed { error, attempts } => { - if let Some(player) = player { - ctx.send( - player, - &PitEvent::Saved { - key: settlement.key, - ok: false, - replayed: false, - xp: 0, - total_xp: 0, - kills: 0, - level: 0, - waited_ms, - x, - y, - error: Some(format!("{error:?}")), - attempts, - }, - ); - } - } - } - } - - fn has_pending_work(&self) -> bool { - !self.backlog.is_empty() - } -} - -impl Entity for Player { - fn entity_id(&self) -> EntityId { - self.id - } -} - -impl Project for Player { - fn view(&self) -> Option { - Some(PlayerView::project( - self, - PlayerViewRest { - hp: self.hp.clamp(0, 255) as u8, - swing: self.swing_anim, - level: self.level.min(u32::from(u16::MAX)) as u16, - dead: self.dead_until.is_some(), - }, - )) - } -} - -impl Entity for Mob { - fn entity_id(&self) -> EntityId { - self.id - } -} - -impl Project for Mob { - fn view(&self) -> Option { - if self.state == MobState::Dead { - return None; - } - let max_hp = tier_stats(self.tier).0; - let tele = if self.state == MobState::Windup { - 255 - (u32::from(self.timer) * 255 / u32::from(WINDUP_TICKS)) - } else { - 0 - }; - Some(MobView::project( - self, - MobViewRest { - hp: (self.hp.max(0) * 100 / max_hp) as u8, - state: self.state as u8, - tele: tele as u8, - }, - )) - } -} - -impl Entity for MobPit { - fn entity_id(&self) -> EntityId { - PIT_ENTITY - } -} - -impl Project for MobPit { - fn view(&self) -> Option { - let clamp16 = |n: usize| n.min(65_535) as u16; - Some(PitView::project( - self, - PitViewRest { - pending: clamp16(self.pending.len()), - slow_ms: self.world.sink_state().latency().as_millis().min(65_535) as u16, - tick_ms: self.tick_ms, - tick_max_ms: self.tick_max_ms, - work_us: self.work_us as u32, - committed: self.committed, - backlog: clamp16(self.backlog.len()), - }, - )) - } -} - -/// Zone numbers for tests and tooling (`GameServer::inspect`). -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct PitStats { - /// Facts emitted and not yet settled (incl. backpressure backlog). - pub pending: usize, - /// Tick interval EMA (ms). - pub tick_ms: f32, - /// Longest tick interval of the last 30 ticks (ms). - pub tick_max_ms: f32, - /// Settlements received. - pub committed: u32, - /// Living mobs. - pub mobs_alive: usize, - /// Players. - pub players: usize, -} - -impl MobPit { - /// Current numbers. - pub fn stats(&self) -> PitStats { - PitStats { - pending: self.pending.len() + self.backlog.len(), - tick_ms: self.tick_ms, - tick_max_ms: self.tick_max_ms, - committed: self.committed, - mobs_alive: self - .mobs - .iter() - .filter(|m| m.state != MobState::Dead) - .count(), - players: self.players.len(), - } - } -} - -/// Bot payload: the direction as a move plus attack always held. -pub fn encode_bot_input(dir: [f32; 2]) -> Vec { - let axis = |v: f32| (v.clamp(-1.0, 1.0) * 127.0).round() as i8; - PitInput { - mx: axis(dir[0]), - my: axis(dir[1]), - buttons: PitInput::ATTACK, - } - .to_payload() -} - -/// Golden test for the projected views (gr-p16): the hand-written -/// `replicate()` loop Mob Pit used before its views were projected from -/// registered entity collections, kept verbatim as the reference. A real -/// host runs a fight (deaths, windups, respawns); every tick the world the -/// runtime projects must equal the legacy one, entity for entity, and the -/// Delta frames built from each must be byte-identical. -#[cfg(test)] -mod projection_golden { - use std::sync::Arc; - use std::time::Duration; - - use distributed_game::replication::{ClientReplica, EncodedWorld}; - use distributed_game::{ - HostConfig, InterestConfig, ManualClock, Role, TicketClaims, Viewer, WorldView, ZoneHost, - }; - - use super::*; - use crate::mobpit::{MobPitWorld, WorldOptions}; - - /// The pre-gr-p16 `MobPit::replicate`, unchanged. - fn legacy_replicate(pit: &MobPit, out: &mut WorldView) { - for p in pit.players.values() { - let s = p.state; - let view = PlayerView { - x: s.x, - y: s.y, - vx: s.vx, - vy: s.vy, - dash: s.dash, - cd: s.cd, - name: p.name.clone(), - hp: p.hp.clamp(0, 255) as u8, - face: p.face, - swing: p.swing_anim, - level: p.level.min(u32::from(u16::MAX)) as u16, - dead: p.dead_until.is_some(), - }; - out.add(u32::from(p.id), &view); - } - for m in &pit.mobs { - if m.state == MobState::Dead { - continue; - } - let max_hp = tier_stats(m.tier).0; - let tele = if m.state == MobState::Windup { - 255 - (u32::from(m.timer) * 255 / u32::from(WINDUP_TICKS)) - } else { - 0 - }; - let view = MobView { - x: m.x, - y: m.y, - face: m.face, - tier: m.tier, - hp: (m.hp.max(0) * 100 / max_hp) as u8, - state: m.state as u8, - tele: tele as u8, - }; - out.add(u32::from(m.id), &view); - } - for (i, (x, y, r)) in PILLARS.iter().enumerate() { - out.add( - 100 + i as u32, - &PillarView { - x: *x, - y: *y, - r: *r, - }, - ); - } - let clamp16 = |n: usize| n.min(65_535) as u16; - let stats = PitView { - pending: clamp16(pit.pending.len()), - slow_ms: pit.world.sink_state().latency().as_millis().min(65_535) as u16, - tick_ms: pit.tick_ms, - tick_max_ms: pit.tick_max_ms, - work_us: pit.work_us as u32, - committed: pit.committed, - backlog: clamp16(pit.backlog.len()), - }; - out.add(PIT_ENTITY, &stats); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn projected_world_and_delta_bytes_match_the_legacy_replicate() { - let celld = crate::mobpit::store::CelldOptions::new( - "http://127.0.0.1:9", - "test-only-unreachable-celld-0123456789", - ) - .unwrap(); - let world = MobPitWorld::open(WorldOptions::temporary(celld)).unwrap(); - let clock = ManualClock::new(); - let mut host = ZoneHost::::new( - "pit-1", - HostConfig { - boot_epoch: 7, - ..HostConfig::default() - }, - Arc::new(clock.clone()), - MobPitConfig { - guests: Guests::default(), - world, - }, - ); - let mut clients = Vec::new(); - for name in ["ana", "bo", "cy"] { - let claims = TicketClaims::new(name, "pit-1", u64::MAX, name, Role::Player); - let joined = host.join(&claims, 0, "").unwrap(); - let Viewer::Player(player) = joined.viewer else { - panic!("player") - }; - clients.push((joined.client, player)); - } - let schema = MobPit::schema(); - let interest = InterestConfig::default(); - let (mut legacy_replica, mut projected_replica) = - (ClientReplica::new(), ClientReplica::new()); - let (mut saw_dead, mut saw_windup, mut saw_hurt_player) = (false, false, false); - for tick in 0..900u32 { - for (i, (client, player)) in clients.iter().enumerate() { - // Walk toward the nearest living mob, swinging all the time. - let me = host.zone().players[player].state; - let target = host - .zone() - .mobs - .iter() - .filter(|m| m.state != MobState::Dead) - .min_by(|a, b| { - dist((me.x, me.y), (a.x, a.y)).total_cmp(&dist((me.x, me.y), (b.x, b.y))) - }) - .map(|m| (m.x, m.y)) - .unwrap_or((0.0, 0.0)); - let (dx, dy) = (target.0 - me.x, target.1 - me.y); - let d = (dx * dx + dy * dy).sqrt().max(1e-3); - let wobble = if (tick / 40 + i as u32).is_multiple_of(3) { - 0.4 - } else { - 1.0 - }; - let payload = PitInput { - mx: (dx / d * 127.0 * wobble) as i8, - my: (dy / d * 127.0 * wobble) as i8, - buttons: PitInput::ATTACK, - } - .to_payload(); - host.push_input(*client, tick + 1, tick, &payload); - } - clock.advance(Duration::from_micros(33_334)); - host.advance(); - host.drain_outbound(); - - let mut legacy = WorldView::default(); - legacy_replicate(host.zone(), &mut legacy); - let projected = host.world_view(); - assert_eq!(projected.entities(), legacy.entities(), "tick {tick}"); - - let view = host.zone().players[&clients[0].1].state; - let view = View::new(view.x, view.y, VIEW_RADIUS); - let frame = |replica: &mut ClientReplica, world: WorldView| { - let encoded = EncodedWorld::build(&schema, world, &interest); - replica - .build_delta(&schema, &encoded, &interest, &view, 1200, tick, tick) - .0 - }; - assert_eq!( - frame(&mut projected_replica, projected), - frame(&mut legacy_replica, legacy), - "delta bytes at tick {tick}" - ); - - let pit = host.zone(); - saw_dead |= pit.mobs.iter().any(|m| m.state == MobState::Dead); - saw_windup |= pit - .mobs - .iter() - .any(|m| m.state == MobState::Windup && m.timer > 0); - saw_hurt_player |= pit.players.values().any(|p| p.hp < PLAYER_HP); - } - assert!(saw_dead, "a mob died (its view is omitted)"); - assert!(saw_windup, "a mob telegraphed (derived `tele`)"); - assert!(saw_hurt_player, "a player took damage"); - } -} +pub use mobpit_rooms::*; diff --git a/examples/game/server/src/predict_trace.rs b/examples/game/server/src/predict_trace.rs index f532af6f..e2082e79 100644 --- a/examples/game/server/src/predict_trace.rs +++ b/examples/game/server/src/predict_trace.rs @@ -321,6 +321,17 @@ fn grid_sample_args() -> Vec> { 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()); + } + } + args +} + /// Every demo step's and pure function's native trace (see the module docs). pub fn traces() -> Value { json!({ @@ -328,6 +339,7 @@ pub fn traces() -> Value { "pures": [ pure_trace::(grid_cell_args()), pure_trace::(grid_sample_args()), + pure_trace::(pit_room_args()), ], "steps": [ trace::(capsule_streams(0)), diff --git a/examples/game/server/tests/mobpit_smoke.rs b/examples/game/server/tests/mobpit_smoke.rs index aa07bd36..4f24887a 100644 --- a/examples/game/server/tests/mobpit_smoke.rs +++ b/examples/game/server/tests/mobpit_smoke.rs @@ -1,24 +1,27 @@ //! Mob Pit + Leaderboard smoke tests, every one against its own `celld dev` -//! serving `examples/game/cells` (`game_demos::devcelld`; `CELLD_BIN` must -//! name celld and the worker must be built — `run.sh smoke` does both). -//! celld relays its outbox Queue to the test's server, whose crawler -//! directory projects the relayed events (the crawler list is eventually -//! consistent with the cells). +//! serving `examples/game/cells` — the rooms (`mobpit_rooms`, one Durable +//! Object per room) and their cells (`game_demos::devcelld`; `CELLD_BIN` +//! must name celld and the worker must be built — `run.sh smoke` does +//! both). celld relays its outbox Queue to the test's services, whose +//! crawler directory and room status project the relayed events +//! (eventually consistent with the cells). //! -//! 1. Facts → celld Crawler cells exactly once (ledger replay), server-side -//! XP, trusted principal; leaderboard occurrences have the same ids live -//! and from history, so rebuild + replay never double count. celld down: -//! the fact is retryable, never stored locally. -//! 2. Bots fight in-process; with 1.5 s of storage latency the tick stays -//! flat while pending facts climb, then everything settles and the -//! boards equal the cells (and a rebuild changes nothing). -//! 3. Crash recovery with the real binary: `kill -9` mid-fight with facts in -//! flight, then celld crashes too; restart both, verify the cells and -//! that a replayed pre-crash fact key does not double-credit. +//! 1. Kill facts → Crawler cells exactly once (ledger replay), server-side +//! XP, trusted principal; the board follows the directory, a rebuild +//! from the cells' history matches it. celld down: the fact is +//! retryable, never stored locally. +//! 2. Bots fight in a room on celld: kills settle in the cells, boards and +//! rebuild equal the cells; the empty room goes dormant, then +//! hibernated, and a new player wakes it with its mobs restored. +//! 3. celld crashes mid-fight (kill -9 of its process tree) and restarts: +//! the room comes back from its snapshot, its unsettled kill facts +//! settle from its outbox, and the cells, the directory and the board +//! agree. +//! 4. Presence: the rooms' lifecycle facts reach the players' presence +//! cells (online, offline, away). use std::net::SocketAddr; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -26,15 +29,17 @@ use distributed::leaderboard::LeaderboardStore; use distributed_game::bots::{run_bots, Behaviour, BotConfig}; use distributed_game::net::TicketKey; use distributed_game::{Fact, FactError, FactSink, FactStamp, ZoneRng}; -use game_demos::common::{random_hex, DemoOptions, RunningDemo}; +use game_demos::common::random_hex; use game_demos::devcelld::DevCelld; use game_demos::mobpit::board::{kill_occurrence, MobKilled}; use game_demos::mobpit::crawler::RECORD_KILL; -use game_demos::mobpit::{self, encode_bot_input, MobPitDemo, MobPitWorld, WorldOptions}; +use game_demos::mobpit::store::kill_sink; +use game_demos::mobpit::{self, encode_bot_input, MobPitWorld, RunningServices, WorldOptions}; use serde_json::{json, Value}; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; -const SECRET: &str = "mobpit-smoke-secret-0123456789abcdef"; +/// The rooms' empty → hibernated delay in these tests. +const HIBERNATE_AFTER_MS: &str = "1500"; /// Walk slowly while turning: the swing cone sweeps around, chasing mobs get hit. struct Spin { @@ -59,7 +64,14 @@ async fn relayed_celld() -> (tokio::net::TcpListener, DevCelld) { listener.local_addr().unwrap(), distributed::bus::CELLD_QUEUE_RELAY_PATH ); - let celld = DevCelld::start(Some(relay)).unwrap_or_else(|error| panic!("celld dev: {error}")); + let celld = DevCelld::start_with( + Some(relay), + &[ + ("ZONE_HIBERNATE_AFTER_MS", HIBERNATE_AFTER_MS), + ("ZONE_PERSIST_MS", "1000"), + ], + ) + .unwrap_or_else(|error| panic!("celld dev: {error}")); (listener, celld) } @@ -155,8 +167,8 @@ async fn facts_commit_once_and_board_rebuilds_identically() { let world = MobPitWorld::start(WorldOptions::new(&dir, celld.options())) .await .unwrap(); - let (_demo, _stop) = start(listener, world.clone()).await; - let sink = world.fact_sink(); + let (_demo, _stop) = start(listener, &celld, world.clone()).await; + let sink = kill_sink(world.celld()).unwrap(); let input = json!({ "mob": "pit-1/m3", "tier": 2, "name": "Ana" }); let first = sink @@ -196,11 +208,6 @@ async fn facts_commit_once_and_board_rebuilds_identically() { )) .await; assert!(matches!(bad_tier, Err(FactError::Permanent(_)))); - let anonymous = Fact::trusted("k", RECORD_KILL, input.clone(), None, FactStamp::default()); - assert!(matches!( - sink.commit(&anonymous).await, - Err(FactError::Permanent(_)) - )); sink.commit(&kill_fact( "kill:t:pit-1:m6:0", "guest-bo", @@ -224,8 +231,8 @@ async fn facts_commit_once_and_board_rebuilds_identically() { // Listed by the directory once their kill events are projected. crawlers_settled(&world, &["guest-ana", "guest-bo"]).await; - // Live updates are fire-and-forget; the replay was deduplicated. - eventually("live board", Duration::from_secs(5), || async { + // The board follows the directory; the replay changed nothing. + eventually("live board", Duration::from_secs(10), || async { world.board().scores("guest-bo").await.unwrap() == (1.0, 60.0) }) .await; @@ -294,18 +301,28 @@ async fn facts_commit_once_and_board_rebuilds_identically() { async fn start( listener: tokio::net::TcpListener, + celld: &DevCelld, world: Arc, -) -> (RunningDemo, tokio::sync::oneshot::Sender<()>) { +) -> (RunningServices, tokio::sync::oneshot::Sender<()>) { let (stop, stopped) = tokio::sync::oneshot::channel::<()>(); - let running = mobpit::spawn(listener, DemoOptions::dev(SECRET), world, async move { - let _ = stopped.await; - }) + let running = mobpit::spawn( + listener, + TicketKey::new(celld.ticket_secret()), + true, + world, + async move { + let _ = stopped.await; + }, + ) .unwrap(); (running, stop) } -fn bot_config(ws: String, bots: usize, duration: Duration) -> BotConfig { - let mut config = BotConfig::new(ws, "pit-1", TicketKey::new(SECRET)); +/// Bots in room `room` on celld (`/rooms/{room}`), with tickets the rooms +/// accept. +fn bot_config(celld: &DevCelld, room: &str, bots: usize, duration: Duration) -> BotConfig { + let mut config = BotConfig::new(celld.ws_url(), room, TicketKey::new(celld.ticket_secret())); + config.path = "/rooms".into(); config.bots = bots; config.input_rate = 30.0; config.duration = duration; @@ -320,140 +337,118 @@ fn spin(i: usize) -> Box { }) } +/// `GET {celld}/rooms/{room}/info` (the room object's debug JSON). +async fn room_info(celld: &DevCelld, room: &str) -> Value { + reqwest_get(&format!("{}/rooms/{room}/info", celld.url())).await +} + +async fn reqwest_get(url: &str) -> Value { + match reqwest::get(url).await { + Ok(response) => response.json().await.unwrap_or(Value::Null), + Err(_) => Value::Null, + } +} + +/// The room's row in the services' room status (`/dev/mobpit/rooms`). +async fn room_status(services: SocketAddr, room: &str) -> Value { + let (_, body) = http(services, "GET", "/dev/mobpit/rooms", None).await; + body["rooms"] + .as_array() + .and_then(|rooms| rooms.iter().find(|r| r["zone"] == room).cloned()) + .unwrap_or(Value::Null) +} + +/// Directory = cells, board = directory. +async fn settled_and_matching(world: &Arc) -> u64 { + let w = world.clone(); + eventually( + "directory equals the cells", + Duration::from_secs(60), + || { + let w = w.clone(); + async move { + let Ok(crawlers) = w.crawlers().await else { + return false; + }; + for c in &crawlers { + let principal = c["principal"].as_str().unwrap(); + let Ok(view) = w.cells().crawler(principal).await else { + return false; + }; + if view.state.map(|s| u64::from(s.kills)) != c["kills"].as_u64() { + return false; + } + } + !crawlers.is_empty() + } + }, + ) + .await; + let w = world.clone(); + eventually( + "board equals the directory", + Duration::from_secs(20), + || { + let w = w.clone(); + async move { + let mut ok = true; + for c in w.crawlers().await.unwrap() { + let (k, x) = w + .board() + .scores(c["principal"].as_str().unwrap()) + .await + .unwrap(); + ok &= k == c["kills"].as_f64().unwrap() && x == c["xp"].as_f64().unwrap(); + } + ok + } + }, + ) + .await; + assert_board_matches_cells(world).await +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn bots_fight_slow_storage_keeps_the_tick_flat_and_boards_match_cells() { +async fn bots_fight_in_a_room_on_celld_and_the_room_sleeps_and_restores() { let dir = temp_dir("bots"); let (listener, celld) = relayed_celld().await; let world = MobPitWorld::start(WorldOptions::new(&dir, celld.options())) .await .unwrap(); - let (demo, stop) = start(listener, world.clone()).await; + let (services, stop) = start(listener, &celld, world.clone()).await; + let room = "pit.0.0"; - // Unknown fields (e.g. an identity) are rejected on the dev routes too. - let (status, _) = http( - demo.addr, - "POST", - "/dev/mobpit/storage", - Some(json!({ "latencyMs": 1, "principal": "x" })), - ) - .await; - assert_eq!(status, 422); - - let server = demo.server.clone(); - let addr = demo.addr; - let probe = async move { - // Fight at full speed first, then make storage slow. - tokio::time::sleep(Duration::from_secs(4)).await; - let (_, storage) = http( - addr, - "POST", - "/dev/mobpit/storage", - Some(json!({ "latencyMs": 1500 })), - ) - .await; - assert_eq!(storage["latencyMs"], 1500); - let mut max_pending = 0; - let mut max_tick: f32 = 0.0; - let until = Instant::now() + Duration::from_secs(6); - while Instant::now() < until { - tokio::time::sleep(Duration::from_millis(250)).await; - if let Some(s) = server.inspect("pit-1", |h| h.zone().stats()).await { - max_pending = max_pending.max(s.pending); - if s.tick_max_ms > 0.0 { - max_tick = max_tick.max(s.tick_max_ms); - } - } - } - (max_pending, max_tick) - }; - let (summary, (max_pending, max_tick)) = tokio::join!( - run_bots(bot_config(demo.ws_url(), 8, Duration::from_secs(11)), spin), - probe - ); + let summary = run_bots(bot_config(&celld, room, 6, Duration::from_secs(10)), spin).await; assert_eq!( summary.joined(), - 8, + 6, "{:?}", summary.bots.iter().find(|b| b.error.is_some()) ); - eprintln!("slow storage: max pending {max_pending}, max tick interval {max_tick:.1} ms"); - assert!( - max_pending >= 1, - "slow storage: facts wait (max {max_pending})" - ); - assert!( - max_tick < 120.0, - "the 30 Hz tick never waits for storage (max interval {max_tick} ms)" - ); + let info = room_info(&celld, room).await; + assert!(info["iterations"].as_u64().unwrap() > 100, "{info}"); + assert!(info["tick"].as_u64().unwrap_or(0) > 0, "{info}"); - // Back to fast storage: everything settles. - http( - demo.addr, - "POST", - "/dev/mobpit/storage", - Some(json!({ "latencyMs": 0 })), - ) - .await; - let w = world.clone(); - eventually("facts settled", Duration::from_secs(60), || { - let w = w.clone(); - async move { w.zone_pending() == 0 && w.sink_state().stats().in_sink == 0 } - }) - .await; - let stats = world.sink_state().stats(); - assert!(stats.committed >= 3, "bots killed mobs: {stats:?}"); - assert_eq!(stats.errors, 0, "{stats:?}"); - - // Boards = cells (fire-and-forget updates have drained, the directory - // has projected every kill), rebuild = same. - let w = world.clone(); - eventually("directory caught up", Duration::from_secs(30), || { - let w = w.clone(); - async move { - let mut total = 0; - for c in w.crawlers().await.unwrap() { - total += c["kills"].as_u64().unwrap(); - } - total == w.sink_state().stats().committed - } + // The bots left: dormant, then hibernated (dropped from memory). + eventually("room hibernated", Duration::from_secs(30), || async { + room_status(services.addr, room).await["status"] == "hibernated" }) .await; - let w = world.clone(); - eventually("board caught up", Duration::from_secs(10), || { - let w = w.clone(); - async move { - let mut ok = true; - for c in w.crawlers().await.unwrap() { - let (k, _) = w - .board() - .scores(c["principal"].as_str().unwrap()) - .await - .unwrap(); - ok &= k == c["kills"].as_f64().unwrap(); - } - ok - } - }) - .await; - let total = assert_board_matches_cells(&world).await; - eprintln!("bots: {stats:?}, durable kills in the cells {total}"); - assert_eq!(total, stats.committed, "one kill per committed fact"); - let (_, rebuild) = http(demo.addr, "POST", "/dev/mobpit/leaderboard/rebuild", None).await; - assert_eq!(rebuild["matchesCells"], true, "{rebuild}"); - assert_eq!(rebuild["identicalTop"], true, "{rebuild}"); - assert_eq!(rebuild["events"].as_u64(), Some(total)); + assert_eq!(room_info(&celld, room).await["awake"], false); - // Read routes. - let (_, board) = http( - demo.addr, - "GET", - "/dev/mobpit/leaderboard?member=bot-0", + // Kills settled exactly once in the cells; boards and rebuild agree. + let total = settled_and_matching(&world).await; + assert!(total >= 1, "bots killed mobs: {total}"); + let (_, rebuild) = http( + services.addr, + "POST", + "/dev/mobpit/leaderboard/rebuild", None, ) .await; - assert_eq!(board["snapshot"]["backend"], "memory"); - assert!(!board["snapshot"]["kills"].as_array().unwrap().is_empty()); - let (_, crawlers) = http(demo.addr, "GET", "/dev/mobpit/crawlers", None).await; + assert_eq!(rebuild["matchesCells"], true, "{rebuild}"); + assert_eq!(rebuild["events"].as_u64(), Some(total)); + let (_, crawlers) = http(services.addr, "GET", "/dev/mobpit/crawlers", None).await; assert_eq!( crawlers["crawlers"] .as_array() @@ -464,190 +459,72 @@ async fn bots_fight_slow_storage_keeps_the_tick_flat_and_boards_match_cells() { total ); + // A new player wakes the room: restored from its snapshot. + let summary = run_bots(bot_config(&celld, room, 1, Duration::from_secs(2)), spin).await; + assert_eq!(summary.joined(), 1); + eventually("room restored", Duration::from_secs(20), || async { + let row = room_status(services.addr, room).await; + row["restored"] == true && row["activations"].as_i64().unwrap_or(0) >= 2 + }) + .await; + stop.send(()).unwrap(); - demo.task.await.unwrap().unwrap(); + services.task.await.unwrap().unwrap(); let _ = std::fs::remove_dir_all(dir); } -struct Binary { - child: Child, - addr: SocketAddr, -} - -async fn start_binary(dir: &Path, celld: &DevCelld, port: u16) -> Binary { - std::fs::create_dir_all(dir).expect("data dir"); - let mut child = Command::new(env!("CARGO_BIN_EXE_game-demos")) - .args(["mobpit", "--port", &port.to_string(), "--dev"]) - .args(["--secret", SECRET, "--data-dir"]) - .arg(dir) - .args(["--celld-url", &celld.url()]) - .env("DISTRIBUTED_INTERNAL_SECRET", celld.secret()) - .env_remove("REDIS_URL") - .stdout(Stdio::piped()) - // Kept in the data dir: the failure messages below point at it. - .stderr( - std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(dir.join("server.log")) - .expect("server log"), - ) - .spawn() - .expect("spawn game-demos"); - let stdout = child.stdout.take().unwrap(); - let mut lines = - tokio::io::BufReader::new(tokio::process::ChildStdout::from_std(stdout).unwrap()).lines(); - let addr = loop { - let line = tokio::time::timeout(Duration::from_secs(60), lines.next_line()) - .await - .expect("server start") - .unwrap() - .expect("server exited"); - if let Some(url) = line.strip_prefix("LISTENING http://") { - break url.parse().unwrap(); - } - }; - // Keep draining stdout so the child never blocks on a full pipe. - tokio::spawn(async move { while let Ok(Some(_)) = lines.next_line().await {} }); - Binary { child, addr } -} - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn kill_dash_nine_mid_fight_keeps_settled_facts_exactly_once() { +async fn a_celld_crash_mid_fight_keeps_kills_exactly_once() { let dir = temp_dir("crash"); - // celld relays its outbox Queue to the server's fixed port (both runs). - let port = game_demos::devcelld::free_port(); - let relay = format!( - "http://127.0.0.1:{port}{}", - distributed::bus::CELLD_QUEUE_RELAY_PATH - ); - let mut celld = - DevCelld::start(Some(relay)).unwrap_or_else(|error| panic!("celld dev: {error}")); - let mut first = start_binary(&dir, &celld, port).await; - let ws = format!("ws://{}", first.addr); - // Settle some kills at full speed, then slow storage so facts are in - // flight at the moment of the crash. - let addr = first.addr; - let fight = tokio::spawn(run_bots(bot_config(ws, 5, Duration::from_secs(30)), spin)); - let start = Instant::now(); - let before = loop { - tokio::time::sleep(Duration::from_millis(200)).await; - let (_, stats) = http(addr, "GET", "/dev/mobpit/stats", None).await; - if stats["sink"]["committed"].as_u64().unwrap_or(0) >= 3 { - break stats; - } - assert!( - start.elapsed() < Duration::from_secs(25), - "no kills: {stats}" - ); - }; - http( - addr, - "POST", - "/dev/mobpit/storage", - Some(json!({ "latencyMs": 1500 })), - ) + let (listener, mut celld) = relayed_celld().await; + let world = MobPitWorld::start(WorldOptions::new(&dir, celld.options())) + .await + .unwrap(); + let (services, stop) = start(listener, &celld, world.clone()).await; + let room = "pit.1.0"; + let fight = tokio::spawn(run_bots( + bot_config(&celld, room, 5, Duration::from_secs(30)), + spin, + )); + // Fight until kills have settled (the fight goes on: more are in + // flight when celld dies). + let w = world.clone(); + eventually("first kills settled", Duration::from_secs(40), || { + let w = w.clone(); + async move { !w.crawlers().await.unwrap_or_default().is_empty() } + }) .await; - let start = Instant::now(); - let at_crash = loop { - tokio::time::sleep(Duration::from_millis(100)).await; - let (_, stats) = http(addr, "GET", "/dev/mobpit/stats", None).await; - if stats["sink"]["inSink"].as_u64().unwrap_or(0) >= 1 - || start.elapsed() > Duration::from_secs(10) - { - break stats; - } - }; - first.child.kill().unwrap(); // SIGKILL: no graceful drain - first.child.wait().unwrap(); - fight.abort(); - let settled_before = at_crash["sink"]["committed"].as_u64().unwrap(); - let in_flight = at_crash["pending"].as_u64().unwrap(); - assert!(settled_before >= before["sink"]["committed"].as_u64().unwrap()); - - // celld crashes too (kill -9 of its process group) and restarts on its - // state; then the server restarts on the same directory: the settled - // facts are all there; of the in-flight ones each either committed - // atomically or not at all. + // celld crashes (kill -9 of its process tree) and restarts on its state. celld.restart().unwrap(); - let mut second = start_binary(&dir, &celld, port).await; - // The directory (persisted in the data dir) catches up with the kill - // events the Queue relays; then it is stable. - let addr = second.addr; - let directory_total = || async move { - let (_, crawlers) = http(addr, "GET", "/dev/mobpit/crawlers", None).await; - crawlers["crawlers"] - .as_array() - .unwrap() - .iter() - .map(|c| c["kills"].as_u64().unwrap()) - .sum::() - }; - let mut total = 0; - let mut stable_since = Instant::now(); - let start = Instant::now(); - while stable_since.elapsed() < Duration::from_secs(3) || total < settled_before { - assert!( - start.elapsed() < Duration::from_secs(60), - "directory never settled: {total} kills, {settled_before} settled before the crash; \ - server log {}, celld logs in {}", - dir.join("server.log").display(), - celld.project().display() + fight.abort(); + // A player wakes the room again: its snapshot and its outbox come back. + let summary = run_bots(bot_config(&celld, room, 1, Duration::from_secs(2)), spin).await; + assert_eq!(summary.joined(), 1, "{:?}", summary.bots); + let total = settled_and_matching(&world).await; + eprintln!("crash: durable kills {total}"); + // Every principal's kill history holds each fact once: the recorded + // kills equal the cell's count (a replay records nothing). + for c in world.crawlers().await.unwrap() { + let view = world + .cells() + .crawler(c["principal"].as_str().unwrap()) + .await + .unwrap(); + assert_eq!( + view.kills.len() as u32, + view.state.unwrap().kills, + "one event per credited kill" ); - tokio::time::sleep(Duration::from_millis(200)).await; - let now = directory_total().await; - if now != total { - total = now; - stable_since = Instant::now(); - } } - eprintln!( - "crash: settled {settled_before}, in flight {in_flight}, durable after restart {total}" - ); - assert!( - total >= settled_before && total <= settled_before + in_flight, - "settled {settled_before} ≤ durable {total} ≤ settled + in flight ({in_flight})" - ); - // The board is rebuilt from the cells of every crawler in the directory - // (at boot, and again now that the directory has caught up). - let (_, rebuild) = http(second.addr, "POST", "/dev/mobpit/leaderboard/rebuild", None).await; + let (_, rebuild) = http( + services.addr, + "POST", + "/dev/mobpit/leaderboard/rebuild", + None, + ) + .await; assert_eq!(rebuild["matchesCells"], true, "{rebuild}"); - let (_, board) = http(second.addr, "GET", "/dev/mobpit/leaderboard", None).await; - let board_kills: f64 = board["snapshot"]["kills"] - .as_array() - .unwrap() - .iter() - .map(|r| r["score"].as_f64().unwrap()) - .sum(); - assert_eq!(board_kills as u64, total, "{board}"); - second.child.kill().unwrap(); - second.child.wait().unwrap(); - - // Replay a pre-crash fact (same key, same input) against the cells: the - // command ledger replays it, nothing is credited twice. - let recent = &before["recent"].as_array().unwrap()[0]; - let key = recent["key"].as_str().unwrap(); - let principal = recent["receipt"]["crawler"].as_str().unwrap(); - let world = MobPitWorld::open(WorldOptions::new(&dir, celld.options())).unwrap(); - let kills_of = |world: Arc| async move { - world - .cells() - .crawler(principal) - .await - .unwrap() - .state - .unwrap() - .kills - }; - let kills_before = kills_of(world.clone()).await; - let replay = world - .fact_sink() - .commit(&kill_fact(key, principal, recent["input"].clone())) - .await - .unwrap(); - assert!(replay.replayed, "pre-crash key replays after the restart"); - assert_eq!(replay.payload, recent["receipt"], "original receipt"); - assert_eq!(kills_of(world.clone()).await, kills_before); + let _ = stop.send(()); let _ = std::fs::remove_dir_all(dir); } @@ -665,9 +542,9 @@ async fn redis_board_projects_and_rebuilds_when_redis_url_is_set() { options.redis_url = Some(url); options.redis_prefix = format!("mobpit-test-{}", random_hex(8)); let world = MobPitWorld::start(options).await.unwrap(); - let (_demo, _stop) = start(listener, world.clone()).await; + let (_services, _stop) = start(listener, &celld, world.clone()).await; assert_eq!(world.board().backend(), "redis"); - let sink = world.fact_sink(); + let sink = kill_sink(world.celld()).unwrap(); for (i, tier) in [1u8, 3].into_iter().enumerate() { let input = json!({ "mob": format!("pit-1/m{i}"), "tier": tier, "name": "Rae" }); let fact = kill_fact(&format!("kill:r:pit-1:m{i}:0"), "guest-rae", input); @@ -690,11 +567,10 @@ async fn redis_board_projects_and_rebuilds_when_redis_url_is_set() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn presence_is_settled_in_presence_cells_from_the_lifecycle_facts() { +async fn presence_is_settled_in_presence_cells_from_the_rooms_lifecycle_facts() { use distributed_game::protocol::Hello; use distributed_game::{ClientMessage, ClientMirror, Role, ServerMessage, TicketClaims}; use futures_util::{SinkExt, StreamExt}; - use game_demos::mobpit::presence::PresenceStatus; use tokio_tungstenite::tungstenite::Message; let dir = temp_dir("presence"); @@ -702,14 +578,16 @@ async fn presence_is_settled_in_presence_cells_from_the_lifecycle_facts() { let world = MobPitWorld::start(WorldOptions::new(&dir, celld.options())) .await .unwrap(); - let (demo, stop) = start(listener, world.clone()).await; + let (services, stop) = start(listener, &celld, world.clone()).await; + let room = "pit.0.2"; let join = |principal: &'static str| { - let url = format!("{}/zones/pit-1", demo.ws_url()); + let url = format!("{}/rooms/{room}", celld.ws_url()); + let key = TicketKey::new(celld.ticket_secret()); async move { let (mut ws, _) = tokio_tungstenite::connect_async(url).await.unwrap(); - let ticket = TicketKey::new(SECRET).sign(&TicketClaims::new( + let ticket = key.sign(&TicketClaims::new( principal, - "pit-1", + room, 4_000_000_000, format!("presence-{principal}"), Role::Player, @@ -739,27 +617,25 @@ async fn presence_is_settled_in_presence_cells_from_the_lifecycle_facts() { } }; let status = |principal: &'static str| { - let world = world.clone(); - move || { - let world = world.clone(); - async move { - world - .presence() - .get(principal) - .map(|e| (e.zone.clone(), e.status)) - } + let addr = services.addr; + async move { + let (_, body) = http( + addr, + "GET", + &format!("/dev/mobpit/presence?principal={principal}"), + None, + ) + .await; + ( + body["presence"]["zone"].as_str().map(str::to_string), + body["presence"]["status"].as_str().map(str::to_string), + ) } }; let mut ana = join("guest-ana").await; let bo = join("guest-bo").await; - let online = Some(("pit-1".to_string(), PresenceStatus::Online)); - eventually("ana online", Duration::from_secs(20), { - let s = status("guest-ana"); - let online = online.clone(); - move || { - let (s, online) = (s.clone(), online.clone()); - async move { s().await == online } - } + eventually("ana online", Duration::from_secs(20), || async { + status("guest-ana").await == (Some(room.into()), Some("online".into())) }) .await; // Graceful leave → offline; a dropped socket → away (grace running). @@ -767,29 +643,14 @@ async fn presence_is_settled_in_presence_cells_from_the_lifecycle_facts() { .await .unwrap(); drop(bo); - eventually("ana offline, bo away", Duration::from_secs(20), { - let (a, b) = (status("guest-ana"), status("guest-bo")); - move || { - let (a, b) = (a.clone(), b.clone()); - async move { - a().await == Some(("pit-1".into(), PresenceStatus::Offline)) - && b().await == Some(("pit-1".into(), PresenceStatus::Away)) - } - } + eventually("ana offline, bo away", Duration::from_secs(20), || async { + status("guest-ana").await == (Some(room.into()), Some("offline".into())) + && matches!( + status("guest-bo").await.1.as_deref(), + Some("away") | Some("offline") + ) }) .await; - let (code, body) = http(demo.addr, "GET", "/dev/mobpit/presence", None).await; - assert_eq!(code, 200); - assert_eq!(body["presence"].as_array().unwrap().len(), 2, "{body}"); - // The transitions are durable in the players' presence cells. - let ana = world.cells().presence("guest-ana").await.unwrap(); - assert_eq!( - (ana["zone"].as_str(), ana["status"].as_str()), - (Some("pit-1"), Some("offline")), - "{ana}" - ); - let bo = world.cells().presence("guest-bo").await.unwrap(); - assert_eq!(bo["status"], "away", "{bo}"); // Lifecycle facts never touch the Crawler cells. assert!(world.crawlers().await.unwrap().is_empty()); let _ = stop.send(()); From b639f1c961c4c7eb4255c880e9dbbe1394efb2b1 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 19:52:11 -0500 Subject: [PATCH 06/87] feat(game): SvelteKit Mob Pit rooms, live minimap, celld overlay The SvelteKit Mob Pit joins the entrance room on celld and follows doorways from room to room; tickets admit the entrance or a room being rejoined (after a 1012 restart), never chosen by a node. - mobpit-service projects the rooms' zone-activity events into `zone_activities` next to `player_stats`, readable over GraphQL @live. - The page: a live minimap (Rooms.graphql @load @live: active, dormant, hibernated, never ran) with restore and sleep toasts, a debug overlay of the room object's node, isolate and activation, doorways drawn on the walls; the room entry points rotate per connection (celld forwards every socket to the room's node). - Playwright: two players on a 3-node production-mode celld fleet (RustFS via docker compose, tests/helpers/fleet.mjs): kill, live read model, doorways into a room on another node, the left room asleep on the minimap, restored on return, another kill settled live. [[tasks/gr-p41-mobpit-rooms-zones]] --- examples/game/client/tests/helpers/fleet.mjs | 131 ++++++ examples/game/server/service/Cargo.toml | 18 +- examples/game/server/service/src/lib.rs | 48 ++- examples/game/server/src/mobpit/world.rs | 2 +- .../src/lib/generated/mobpit/constants.ts | 11 +- .../src/lib/generated/mobpit/events.ts | 217 ++++------ .../src/lib/generated/mobpit/index.ts | 1 + .../src/lib/generated/mobpit/pures.ts | 60 +++ .../src/lib/generated/mobpit/schema.ts | 24 +- .../src/lib/generated/mobpit/surface.json | 392 ++++++++---------- .../game/sveltekit/src/lib/pit/Rooms.graphql | 15 + .../game/sveltekit/src/lib/pit/Rooms.svelte | 80 ++++ examples/game/sveltekit/src/lib/pit/loop.ts | 25 +- .../game/sveltekit/src/lib/server/game.ts | 33 +- .../sveltekit/src/routes/pit/+page.server.ts | 5 +- .../sveltekit/src/routes/pit/+page.svelte | 70 +++- examples/game/sveltekit/tests/pit.test.mjs | 156 +++++-- 17 files changed, 868 insertions(+), 420 deletions(-) create mode 100644 examples/game/client/tests/helpers/fleet.mjs create mode 100644 examples/game/sveltekit/src/lib/generated/mobpit/pures.ts create mode 100644 examples/game/sveltekit/src/lib/pit/Rooms.graphql create mode 100644 examples/game/sveltekit/src/lib/pit/Rooms.svelte diff --git a/examples/game/client/tests/helpers/fleet.mjs b/examples/game/client/tests/helpers/fleet.mjs new file mode 100644 index 00000000..ad07ba34 --- /dev/null +++ b/examples/game/client/tests/helpers/fleet.mjs @@ -0,0 +1,131 @@ +/** + * A local multi-node celld fleet for one Node test: one RustFS container + * (docker compose, a project and port of its own) holding the fleet + * bucket, the demos' cells worker (rooms + cells) deployed to it with + * `celld deploy`, and N nodes in production mode. `stop()` stops the nodes + * it started and removes the container (`docker compose down -v`). + * + * Returns null when docker, the aws CLI or celld is missing, unless + * `MOBPIT_FLEET_REQUIRED=1` (then it throws). + */ +import { spawn, spawnSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { freePort } from './celld.mjs'; + +const CELLS = fileURLToPath(new URL('../../../cells/', import.meta.url)); +const RELAY_PATH = '/internal/celld-queue/relay'; + +function skip(reason) { + if (process.env.MOBPIT_FLEET_REQUIRED === '1') throw new Error(`fleet required: ${reason}`); + console.error(`no celld fleet: ${reason}`); + return null; +} + +async function waitFor(url, ms) { + const deadline = Date.now() + ms; + for (;;) { + try { + if ((await fetch(url)).ok) return; + } catch { + // not up yet + } + if (Date.now() > deadline) throw new Error(`${url} not ready`); + await new Promise((r) => setTimeout(r, 250)); + } +} + +export async function startFleet({ relayPort, nodes = 3, vars = {} } = {}) { + const bin = process.env.CELLD_BIN; + if (!bin) return skip('CELLD_BIN is not set'); + for (const tool of ['docker', 'aws']) { + if (spawnSync(tool, ['--version']).status !== 0) return skip(`${tool} is not available`); + } + const dir = mkdtempSync(join(tmpdir(), 'mobpit-fleet-js-')); + const s3Port = await freePort(); + const project = `mobpit-fleet-js-${process.pid}-${s3Port}`; + const aws = { + ...process.env, + AWS_ACCESS_KEY_ID: 'fleetjs', + AWS_SECRET_ACCESS_KEY: 'fleetjssecret123', + AWS_REGION: 'us-east-1', + S3_ENDPOINT: `http://127.0.0.1:${s3Port}` + }; + const compose = join(dir, 's3-compose.yaml'); + writeFileSync( + compose, + `services:\n s3:\n image: rustfs/rustfs:1.0.3\n command: ["/data"]\n environment:\n RUSTFS_ADDRESS: "0.0.0.0:9000"\n RUSTFS_ACCESS_KEY: ${aws.AWS_ACCESS_KEY_ID}\n RUSTFS_SECRET_KEY: ${aws.AWS_SECRET_ACCESS_KEY}\n ports: ["127.0.0.1:${s3Port}:9000"]\n` + ); + const children = []; + const down = () => { + for (const child of children) if (child.exitCode === null) child.kill('SIGKILL'); + spawnSync('docker', ['compose', '-p', project, '-f', compose, 'down', '-v'], { stdio: 'ignore' }); + }; + try { + const up = spawnSync('docker', ['compose', '-p', project, '-f', compose, 'up', '-d'], { encoding: 'utf8' }); + if (up.status !== 0) throw new Error(`docker compose up: ${up.stderr}`); + const deadline = Date.now() + 60_000; + for (;;) { + const mb = spawnSync('aws', ['--endpoint-url', aws.S3_ENDPOINT, 's3', 'mb', 's3://mobpit-fleet'], { env: aws, encoding: 'utf8' }); + if (mb.status === 0 || /BucketAlready/.test(mb.stderr)) break; + if (Date.now() > deadline) throw new Error(`bucket: ${mb.stderr}`); + await new Promise((r) => setTimeout(r, 500)); + } + const secret = `test-only-${randomBytes(24).toString('hex')}`; + const ticketSecret = randomBytes(32).toString('hex'); + const ports = []; + for (let i = 0; i < nodes; i++) ports.push({ name: `node-${'abcdef'[i]}`, pub: await freePort(), op: await freePort() }); + const worker = join(dir, 'worker'); + mkdirSync(worker, { recursive: true }); + cpSync(join(CELLS, 'build'), join(worker, 'build'), { recursive: true }); + const config = JSON.parse( + readFileSync(join(CELLS, 'wrangler.jsonc'), 'utf8') + .split('\n') + .filter((l) => !l.trimStart().startsWith('//')) + .join('\n') + ); + Object.assign(config.vars, { + DISTRIBUTED_INTERNAL_SECRET: secret, + GAME_TICKET_SECRET: ticketSecret, + CELLD_QUEUE_RELAY_URL: `http://127.0.0.1:${relayPort}${RELAY_PATH}`, + CELLD_ZONE_NODES: ports.map((p) => `${p.name}=127.0.0.1:${p.op}`).join(','), + ...vars + }); + writeFileSync(join(worker, 'wrangler.json'), JSON.stringify(config)); + const deploy = spawnSync(bin, ['deploy', worker, '--bucket', 's3://mobpit-fleet/fleet'], { env: aws, encoding: 'utf8' }); + if (deploy.status !== 0) throw new Error(`celld deploy: ${deploy.stdout}${deploy.stderr}`); + for (const p of ports) { + const ndir = join(dir, p.name); + mkdirSync(ndir, { recursive: true }); + const child = spawn( + bin, + ['--bucket', 's3://mobpit-fleet/fleet', '--listen', `127.0.0.1:${p.pub}`, '--internal-listen', `127.0.0.1:${p.op}`, '--advertise', `127.0.0.1:${p.op}`], + { + env: { ...aws, CELLD_NODE: p.name, CELLD_WATCH: join(ndir, 'watch'), CELLD_READY_FLEET_GATE_MS: '0', RUST_LOG: 'warn' }, + stdio: 'ignore' + } + ); + children.push(child); + } + await Promise.all(ports.map((p) => waitFor(`http://127.0.0.1:${p.pub}/health`, 90_000))); + const urls = ports.map((p) => `http://127.0.0.1:${p.pub}`); + return { + urls, + url: urls[0], + nodes: ports.map((p) => p.name), + ticketSecret, + env: { CELLD_URL: urls[0], DISTRIBUTED_INTERNAL_SECRET: secret, DISTRIBUTED_GAME_SECRET: ticketSecret }, + async stop() { + down(); + rmSync(dir, { recursive: true, force: true }); + } + }; + } catch (error) { + down(); + throw error; + } +} diff --git a/examples/game/server/service/Cargo.toml b/examples/game/server/service/Cargo.toml index ce1f902f..7c664acb 100644 --- a/examples/game/server/service/Cargo.toml +++ b/examples/game/server/service/Cargo.toml @@ -1,9 +1,10 @@ -# Mob Pit's business-side read model: a Distributed service that projects -# the Crawler cells' published `crawler.mob_killed` domain events (celld -# drains each cell outbox to a Queue; its consumer relays them through the -# zone server onto a SQLite bus) into a `player_stats` SQL table and -# serves it over GraphQL with `@live`. Read-only: it has no commands, so -# nothing here can credit XP. +# Mob Pit's business-side read models: a Distributed service that projects +# the Crawler cells' published `crawler.mob_killed` domain events and the +# rooms' `zone.activity_changed` events (celld drains each cell outbox to a +# Queue; its consumer relays them through the services onto a SQLite bus) +# into `player_stats` and `zone_activities` SQL tables and serves them over +# GraphQL with `@live`. Read-only: it has no commands, so nothing here can +# credit XP. [package] name = "mobpit_service" version = "0.1.0" @@ -23,6 +24,8 @@ mobpit_crawler = { path = "../crawler" } # `redis`: the Redis leaderboards the zone server writes, served as GraphQL # board roots (`xpLeaderboard`, `killsMyRank`, … with `@live`). distributed = { path = "../../../..", features = ["sqlite", "http", "graphql", "redis"] } +# The rooms' zone status read model (`zone_activities`, the minimap). +distributed_game = { path = "../../../../distributed_game", features = ["cell"] } axum = { version = "0.8", default-features = false, features = ["json", "tokio", "http1", "ws"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -30,9 +33,8 @@ sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "time", "sync"] } [dev-dependencies] -# The zone server's world: real Crawler cells + the fact sink + the bus drain. +# The services' world: real Crawler cells + the relay + the bus drain. game_demos = { path = ".." } -distributed_game = { path = "../../../../distributed_game", features = ["cell"] } async-graphql = "7" futures-util = { version = "0.3", default-features = false, features = ["std"] } diff --git a/examples/game/server/service/src/lib.rs b/examples/game/server/service/src/lib.rs index 57c281d7..c7bdd084 100644 --- a/examples/game/server/service/src/lib.rs +++ b/examples/game/server/service/src/lib.rs @@ -15,6 +15,10 @@ //! - [`PlayerStats`] is the table: one row per principal, the Crawler's //! post-kill state ([`mobpit_crawler::CrawlerState`]). The leaderboard is a //! query over it (`order_by: xp desc`). +//! - [`ZoneActivities`] (`zone_activities`): every room's lifecycle as its +//! zone-activity cell published it (`distributed_game::world::zone_status`): +//! active / dormant / hibernated, players, restored, and — a debug +//! detail — the celld node. The pages' minimap is a `@live` query over it. //! - [`PLAYER_STATS`] maps `CrawlerMobKilledDomainEvent` to the //! [`SavePlayerStats`] upsert. The projector runtime keeps the cursor, //! skips redelivered messages and commits each projection atomically. @@ -46,11 +50,22 @@ use distributed::{ Application, ApplicationManifest, RelationalReadModel, SqliteRepository, SurfaceSpec, }; +/// The rooms' status rows and their projection. +pub use distributed_game::world::zone_status::{ZoneActivities, ZONE_ACTIVITIES}; /// The read model and its projection (shared with the zone server). pub use mobpit_crawler::stats::{ - projector, read_model_catalog, PlayerStats, SavePlayerStats, APPLICATION, EPOCH, PLAYER_STATS, - PROJECTOR, + projector, PlayerStats, SavePlayerStats, APPLICATION, EPOCH, PLAYER_STATS, PROJECTOR, }; + +/// Both tables: `player_stats` and `zone_activities`. +pub fn read_model_catalog() -> distributed::ReadModelCatalog { + mobpit_crawler::stats::read_model_catalog().table_schema(ZoneActivities::schema().clone()) +} + +/// The projector of `zone_activities`, owned by this application. +pub fn rooms_projector() -> distributed::graphql::SurfaceProjector { + distributed_game::world::zone_status::activity_projector_for(APPLICATION) +} /// The one engine role. Every signed-in player reads the whole board. pub const ROLE: &str = "user"; /// Bus consumer group of this process's board change notices (every @@ -81,6 +96,11 @@ pub fn permissions() -> ModelPermissions { ModelPermissions::new().grant(ROLE, read().all_columns()) } +/// The rooms' status is public to signed-in players too. +pub fn room_permissions() -> ModelPermissions { + ModelPermissions::new().grant(ROLE, read().all_columns()) +} + async fn project( context: CausalProjectorContext, projection: ModeledProjection, @@ -88,6 +108,13 @@ async fn project( projection.apply(PLAYER_STATS, &context).await } +async fn project_rooms( + context: CausalProjectorContext, + projection: ModeledProjection, +) -> Result<(), HandlerError> { + projection.apply(ZONE_ACTIVITIES, &context).await +} + /// The consumer service (bus consumer identity = [`APPLICATION`]): the /// modeled projector over `read_models`. pub fn projection_service(read_models: SqliteRepository) -> Service { @@ -95,7 +122,9 @@ pub fn projection_service(read_models: SqliteRepository) -> Service { Routes::new() .with_read_model_store(read_models) .modeled_projector(projector()) - .handle(project), + .handle(project) + .modeled_projector(rooms_projector()) + .handle(project_rooms), ) } @@ -116,6 +145,12 @@ fn grants() -> BTreeMap> { .or_default() .insert(PlayerStats::schema().model_name.clone(), grant); } + for (role, grant) in room_permissions().surface_grants() { + grants + .entry(role) + .or_default() + .insert(ZoneActivities::schema().model_name.clone(), grant); + } grants } @@ -124,8 +159,8 @@ fn full_surface() -> Surface { .expect("mobpit Surface should build") .with_leaderboards(leaderboard_surfaces()) .expect("mobpit boards should attach") - .with_projection_owners([projector().into()]) - .expect("mobpit projector should bind") + .with_projection_owners([projector().into(), rooms_projector().into()]) + .expect("mobpit projectors should bind") } /// Logical application artifact (`distributed describe`, the lifecycle). @@ -191,7 +226,8 @@ pub fn build_engine( .roles(&[ROLE]) .client_application_surface(APPLICATION, [ROLE], [ROLE]) .model::(permissions()) - .client_projection_owners([projector().into()]) + .model::(room_permissions()) + .client_projection_owners([projector().into(), rooms_projector().into()]) .identity(identity.config()) .graphiql(false) .change_stream(repo.read_model_changes()) diff --git a/examples/game/server/src/mobpit/world.rs b/examples/game/server/src/mobpit/world.rs index 61e91318..2ea2845b 100644 --- a/examples/game/server/src/mobpit/world.rs +++ b/examples/game/server/src/mobpit/world.rs @@ -16,8 +16,8 @@ use super::directory::CrawlerDirectory; use super::store::{CelldOptions, CrawlerCells}; use crate::common::random_hex; use crate::common::relay::{ProjectionBus, ProjectionPublisher}; -use distributed::leaderboard::LeaderboardChangePublisher; use crate::common::zone_status::ZoneStatusView; +use distributed::leaderboard::LeaderboardChangePublisher; /// Bus consumer group of the rooms' zone status projection here. pub const ZONE_STATUS_CONSUMER: &str = "mobpit-rooms"; diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts b/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts index 0e96cb0a..e01248b1 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts @@ -1,7 +1,7 @@ // @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 } from './events.ts'; +import type { PitRules, PitWorld } from './events.ts'; /** `pit_rules`, as the client decodes it. */ export const PIT_RULES: PitRules = { @@ -15,3 +15,12 @@ export const PIT_RULES: PitRules = { attackHalfAngle: 1.0471975803375244, swingFrames: 6 }; + +/** `pit_world`, as the client decodes it. */ +export const PIT_WORLD: PitWorld = { + defaultSeed: 186474221, + half: 20, + doorHalf: 2.5, + maxRoom: 1000000, + biomes: ['highlands', 'steppe', 'forest', 'meadow'] +}; diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/events.ts b/examples/game/sveltekit/src/lib/generated/mobpit/events.ts index c748ce26..a3f5878b 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/events.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/events.ts @@ -3,124 +3,6 @@ // 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 `BackgroundStats` (`#[derive(GameRecord)]`). */ -export interface BackgroundStats { - readonly enqueued: number; - readonly applied: number; - readonly dropped: number; - readonly failed: number; -} - -const BACKGROUND_STATS_TYPE: WireType = { - type: 'record', - fields: [ - ['enqueued', { type: 'u64' }], - ['applied', { type: 'u64' }], - ['dropped', { type: 'u64' }], - ['failed', { type: 'u64' }], - ] -}; - -/** Record `BoardRow` (`#[derive(GameRecord)]`). */ -export interface BoardRow { - readonly rank: number; - readonly member: string; - readonly name: string; - readonly score: number; -} - -const BOARD_ROW_TYPE: WireType = { - type: 'record', - fields: [ - ['rank', { type: 'u32' }], - ['member', { type: 'short_str' }], - ['name', { type: 'short_str' }], - ['score', { type: 'f64' }], - ] -}; - -/** Record `Scores` (`#[derive(GameRecord)]`). */ -export interface Scores { - readonly kills: number; - readonly xp: number; -} - -const SCORES_TYPE: WireType = { - type: 'record', - fields: [ - ['kills', { type: 'f64' }], - ['xp', { type: 'f64' }], - ] -}; - -/** Record `Mismatch` (`#[derive(GameRecord)]`). */ -export interface Mismatch { - readonly member: string; - readonly cell: Scores; - readonly board: Scores; -} - -const MISMATCH_TYPE: WireType = { - type: 'record', - fields: [ - ['member', { type: 'short_str' }], - ['cell', SCORES_TYPE], - ['board', SCORES_TYPE], - ] -}; - -/** Record `RebuildReport` (`#[derive(GameRecord)]`). */ -export interface RebuildReport { - readonly backend: string; - readonly crawlers: number; - readonly events: number; - readonly applied: number; - readonly duplicates: number; - readonly identicalTop: boolean; - readonly matchesCells: boolean; - readonly mismatches: readonly Mismatch[]; - readonly tookMs: number; -} - -const REBUILD_REPORT_TYPE: WireType = { - type: 'record', - fields: [ - ['backend', { type: 'short_str' }], - ['crawlers', { type: 'u32' }], - ['events', { type: 'u32' }], - ['applied', { type: 'u32' }], - ['duplicates', { type: 'u32' }], - ['identicalTop', { type: 'bool' }], - ['matchesCells', { type: 'bool' }], - ['mismatches', { type: 'list', item: MISMATCH_TYPE }], - ['tookMs', { type: 'f64' }], - ] -}; - -/** Record `BoardSnapshot` (`#[derive(GameRecord)]`). */ -export interface BoardSnapshot { - readonly version: number; - readonly backend: string; - readonly members: number; - readonly kills: readonly BoardRow[]; - readonly xp: readonly BoardRow[]; - readonly background: BackgroundStats; - readonly lastRebuild: RebuildReport | undefined; -} - -const BOARD_SNAPSHOT_TYPE: WireType = { - type: 'record', - fields: [ - ['version', { type: 'u64' }], - ['backend', { type: 'short_str' }], - ['members', { type: 'u64' }], - ['kills', { type: 'list', item: BOARD_ROW_TYPE }], - ['xp', { type: 'list', item: BOARD_ROW_TYPE }], - ['background', BACKGROUND_STATS_TYPE], - ['lastRebuild', { type: 'optional', item: REBUILD_REPORT_TYPE }], - ] -}; - /** Record `MobTier` (`#[derive(GameRecord)]`). */ export interface MobTier { readonly tier: number; @@ -138,6 +20,27 @@ export interface MoveState { readonly cd: number; } +/** Record `PitRoomContext` (`#[derive(GameRecord)]`). */ +export interface PitRoomContext { + readonly seed: number; + readonly x: number; + readonly y: number; +} + +/** Record `RoomMob` (`#[derive(GameRecord)]`). */ +export interface RoomMob { + readonly tier: number; + readonly x: number; + readonly y: number; +} + +/** Record `PitRoomLayout` (`#[derive(GameRecord)]`). */ +export interface PitRoomLayout { + readonly biome: number; + readonly danger: number; + readonly mobs: readonly RoomMob[]; +} + /** Record `PitRules` (`#[derive(GameRecord)]`). */ export interface PitRules { readonly tiers: readonly MobTier[]; @@ -147,6 +50,15 @@ export interface PitRules { readonly swingFrames: number; } +/** Record `PitWorld` (`#[derive(GameRecord)]`). */ +export interface PitWorld { + readonly defaultSeed: number; + readonly half: number; + readonly doorHalf: number; + readonly maxRoom: number; + readonly biomes: readonly string[]; +} + /** Record `Profile` (`#[derive(GameRecord)]`). */ export interface Profile { readonly xp: number; @@ -173,17 +85,25 @@ const PROFILE_TYPE: WireType = { export interface HelloEvent { readonly entity: number; readonly half: number; - readonly boot: string; readonly attackRange: number; readonly attackArcCos: number; + readonly roomX: number; + readonly roomY: number; + readonly seed: number; + readonly restored: boolean; + readonly roomKills: number; } const HELLO_LAYOUT: PayloadLayout = [ ['entity', { type: 'u32' }], ['half', { type: 'f32' }], - ['boot', { type: 'short_str' }], ['attackRange', { type: 'f32' }], ['attackArcCos', { type: 'f32' }], + ['roomX', { type: 'i32' }], + ['roomY', { type: 'i32' }], + ['seed', { type: 'u32' }], + ['restored', { type: 'bool' }], + ['roomKills', { type: 'u32' }], ]; /** Event `profile` (id 2). */ @@ -243,11 +163,6 @@ const SAVED_LAYOUT: PayloadLayout = [ ['attempts', { type: 'u32' }], ]; -/** Event `board` (id 5). */ -export type BoardEvent = BoardSnapshot; - -const BOARD_PAYLOAD: WireType = BOARD_SNAPSHOT_TYPE; - /** Event `died` (id 6). */ export interface DiedEvent { readonly respawnMs: number; @@ -257,13 +172,27 @@ const DIED_LAYOUT: PayloadLayout = [ ['respawnMs', { type: 'u32' }], ]; +/** Message `loaded` (id 1). */ +export interface LoadedMessage { + readonly principal: string; + readonly xp: number; + readonly level: number; + readonly kills: number; +} + +const MESSAGE_LOADED_LAYOUT: PayloadLayout = [ + ['principal', { type: 'short_str' }], + ['xp', { type: 'u64' }], + ['level', { type: 'u32' }], + ['kills', { type: 'u32' }], +]; + /** Event type ids by name. */ export const EVENT = { hello: 1, profile: 2, kill: 3, saved: 4, - board: 5, died: 6, } as const; @@ -273,7 +202,6 @@ export interface Events { profile: ProfileEvent; kill: KillEvent; saved: SavedEvent; - board: BoardEvent; died: DiedEvent; } @@ -303,12 +231,6 @@ export const EVENTS: GameEventCodecs = { encode: (value) => encodePayload(SAVED_LAYOUT, value), decode: (payload) => decodePayload(SAVED_LAYOUT, payload) as unknown as SavedEvent }, - board: { - id: 5, - name: 'board', - encode: (value) => encodeValue(BOARD_PAYLOAD, value), - decode: (payload) => decodeValue(BOARD_PAYLOAD, payload) as BoardEvent - }, died: { id: 6, name: 'died', @@ -333,3 +255,32 @@ export function onEvent( ): () => void { return onGameEvent(client, EVENTS, name, handler); } + +/** Zone inbox message type ids by name. */ +export const MESSAGE = { + loaded: 1, +} as const; + +/** Zone inbox message payload types by name. */ +export interface Messages { + loaded: LoadedMessage; +} + +/** Codecs of every zone inbox message. */ +export const MESSAGES: GameEventCodecs = { + loaded: { + id: 1, + name: 'loaded', + encode: (value) => encodePayload(MESSAGE_LOADED_LAYOUT, value), + decode: (payload) => decodePayload(MESSAGE_LOADED_LAYOUT, payload) as unknown as LoadedMessage + }, +}; + +/** Encode a zone inbox message: `typeId` and `payload` of a `/admin/zone-message` body (base64url the payload). */ +export function encodeMessage( + name: K, + value: Messages[K] +): { readonly typeId: number; readonly payload: Uint8Array } { + const codec = MESSAGES[name]; + return { typeId: codec.id, payload: codec.encode(value) }; +} diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/index.ts b/examples/game/sveltekit/src/lib/generated/mobpit/index.ts index 481102c6..1bb1759f 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/index.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/index.ts @@ -6,4 +6,5 @@ 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/sveltekit/src/lib/generated/mobpit/pures.ts b/examples/game/sveltekit/src/lib/generated/mobpit/pures.ts new file mode 100644 index 00000000..e2d25d5c --- /dev/null +++ b/examples/game/sveltekit/src/lib/generated/mobpit/pures.ts @@ -0,0 +1,60 @@ +// @generated by `distributed game-client` from game surface `mobpit` (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 { PitRoomContext, PitRoomLayout } from './events.ts'; + +const PIT_ROOM_CONTEXT_TYPE: WireType = { + type: 'record', + fields: [ + ['seed', { type: 'u32' }], + ['x', { type: 'i32' }], + ['y', { type: 'i32' }], + ] +}; + +const ROOM_MOB_TYPE: WireType = { + type: 'record', + fields: [ + ['tier', { type: 'u8' }], + ['x', { type: 'f32' }], + ['y', { type: 'f32' }], + ] +}; + +const PIT_ROOM_LAYOUT_TYPE: WireType = { + type: 'record', + fields: [ + ['biome', { type: 'u8' }], + ['danger', { type: 'u8' }], + ['mobs', { type: 'list', item: ROOM_MOB_TYPE }], + ] +}; + +/** The pure functions (`#[game_pure]`) of Cargo package `game_sim`: wrapper name → module export. */ +export const PURES = { + pitRoom: { export: 'pit_room', 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/mobpit.wasm', import.meta.url); + +/** Typed calls of the compiled functions (synchronous; they throw until `ready` resolves). */ +export interface MobpitPureFns { + /** `pit_room` (`dg_pure_pit_room`). */ + pitRoom(arg: PitRoomContext): PitRoomLayout; +} + +/** The `mobpit` pure functions plus `ready` / `loaded()` / `module()`. */ +export type MobpitPures = WasmPures; + +/** + * Load the compiled pure functions (default `PURES_WASM_URL`; a URL loaded by + * the predicted step too is instantiated once). + */ +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 }, + }); +} diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/schema.ts b/examples/game/sveltekit/src/lib/generated/mobpit/schema.ts index 3d9f5f00..8019b0b3 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/schema.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/schema.ts @@ -131,18 +131,16 @@ export function pillarFields(entity: { readonly fields: EntityFields }): PillarF export interface PitFields { /** `u16` */ readonly pending: number; + /** `u32` */ + readonly committed: number; /** `u16` */ - readonly slowMs: number; + readonly backlog: number; /** `f32` */ readonly tickMs: number; /** `f32` */ readonly tickMaxMs: number; /** `u32` */ - readonly workUs: number; - /** `u32` */ - readonly committed: number; - /** `u16` */ - readonly backlog: number; + readonly kills: number; } /** True for a `pit` entity. */ @@ -155,12 +153,11 @@ export function pitFields(entity: { readonly fields: EntityFields }): PitFields const f = entity.fields; return { pending: fieldNumber(f, 'pending'), - slowMs: fieldNumber(f, 'slow_ms'), - tickMs: fieldNumber(f, 'tick_ms'), - tickMaxMs: fieldNumber(f, 'tick_max_ms'), - workUs: fieldNumber(f, 'work_us'), committed: fieldNumber(f, 'committed'), backlog: fieldNumber(f, 'backlog'), + tickMs: fieldNumber(f, 'tick_ms'), + tickMaxMs: fieldNumber(f, 'tick_max_ms'), + kills: fieldNumber(f, 'kills'), }; } @@ -196,12 +193,11 @@ export const FIELD_TYPES = { }, pit: { pending: { type: 'u16' }, - slow_ms: { type: 'u16' }, - tick_ms: { type: 'f32' }, - tick_max_ms: { type: 'f32' }, - work_us: { type: 'u32' }, committed: { type: 'u32' }, backlog: { type: 'u16' }, + tick_ms: { type: 'f32' }, + tick_max_ms: { type: 'f32' }, + kills: { type: 'u32' }, }, } as const satisfies Record>; diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/surface.json b/examples/game/sveltekit/src/lib/generated/mobpit/surface.json index 05ed196c..6c0a39c7 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/surface.json +++ b/examples/game/sveltekit/src/lib/generated/mobpit/surface.json @@ -36,6 +36,25 @@ } ] } + }, + { + "name": "pit_world", + "type": { + "name": "PitWorld", + "type": "record" + }, + "value": { + "biomes": [ + "highlands", + "steppe", + "forest", + "meadow" + ], + "default_seed": 186474221, + "door_half": 2.5, + "half": 20.0, + "max_room": 1000000 + } } ], "events": [ @@ -57,21 +76,45 @@ } }, { - "name": "boot", + "name": "attack_range", "type": { - "type": "short_str" + "type": "f32" } }, { - "name": "attack_range", + "name": "attack_arc_cos", "type": { "type": "f32" } }, { - "name": "attack_arc_cos", + "name": "room_x", "type": { - "type": "f32" + "type": "i32" + } + }, + { + "name": "room_y", + "type": { + "type": "i32" + } + }, + { + "name": "seed", + "type": { + "type": "u32" + } + }, + { + "name": "restored", + "type": { + "type": "bool" + } + }, + { + "name": "room_kills", + "type": { + "type": "u32" } } ], @@ -224,17 +267,6 @@ "kind": "fields" } }, - { - "id": 5, - "name": "board", - "payload": { - "kind": "value", - "type": { - "name": "BoardSnapshot", - "type": "record" - } - } - }, { "id": 6, "name": "died", @@ -542,23 +574,23 @@ }, { "correction": null, - "name": "slow_ms", + "name": "committed", "priority": 1.0, "type": { - "type": "u16" + "type": "u32" } }, { "correction": null, - "name": "tick_ms", + "name": "backlog", "priority": 1.0, "type": { - "type": "f32" + "type": "u16" } }, { "correction": null, - "name": "tick_max_ms", + "name": "tick_ms", "priority": 1.0, "type": { "type": "f32" @@ -566,27 +598,19 @@ }, { "correction": null, - "name": "work_us", + "name": "tick_max_ms", "priority": 1.0, "type": { - "type": "u32" + "type": "f32" } }, { "correction": null, - "name": "committed", + "name": "kills", "priority": 1.0, "type": { "type": "u32" } - }, - { - "correction": null, - "name": "backlog", - "priority": 1.0, - "type": { - "type": "u16" - } } ], "id": 4, @@ -594,6 +618,41 @@ "priority": 1.0 } ], + "messages": [ + { + "id": 1, + "name": "loaded", + "payload": { + "fields": [ + { + "name": "principal", + "type": { + "type": "short_str" + } + }, + { + "name": "xp", + "type": { + "type": "u64" + } + }, + { + "name": "level", + "type": { + "type": "u32" + } + }, + { + "name": "kills", + "type": { + "type": "u32" + } + } + ], + "kind": "fields" + } + } + ], "name": "mobpit", "predict": { "abi": 1, @@ -603,213 +662,135 @@ "state": "MoveState" }, "protocolVersion": 1, - "records": [ + "pures": [ { - "fields": [ - { - "name": "enqueued", - "type": { - "type": "u64" - } - }, - { - "name": "applied", - "type": { - "type": "u64" - } - }, - { - "name": "dropped", - "type": { - "type": "u64" - } - }, - { - "name": "failed", - "type": { - "type": "u64" - } - } - ], - "name": "BackgroundStats" - }, + "abi": 1, + "arg": { + "name": "PitRoomContext", + "type": "record" + }, + "export": "pit_room", + "output": { + "name": "PitRoomLayout", + "type": "record" + }, + "package": "game_sim" + } + ], + "records": [ { "fields": [ { - "name": "rank", - "type": { - "type": "u32" - } - }, - { - "name": "member", + "name": "tier", "type": { - "type": "short_str" + "type": "u8" } }, { - "name": "name", + "name": "radius", "type": { - "type": "short_str" + "type": "f32" } }, { - "name": "score", + "name": "tele", "type": { - "type": "f64" + "type": "f32" } } ], - "name": "BoardRow" + "name": "MobTier" }, { "fields": [ { - "name": "version", - "type": { - "type": "u64" - } - }, - { - "name": "backend", + "name": "x", "type": { - "type": "short_str" + "type": "f32" } }, { - "name": "members", + "name": "y", "type": { - "type": "u64" + "type": "f32" } }, { - "name": "kills", + "name": "vx", "type": { - "item": { - "name": "BoardRow", - "type": "record" - }, - "type": "list" + "type": "f32" } }, { - "name": "xp", + "name": "vy", "type": { - "item": { - "name": "BoardRow", - "type": "record" - }, - "type": "list" + "type": "f32" } }, { - "name": "background", + "name": "dash", "type": { - "name": "BackgroundStats", - "type": "record" + "type": "u8" } }, { - "name": "last_rebuild", + "name": "cd", "type": { - "item": { - "name": "RebuildReport", - "type": "record" - }, - "type": "optional" + "type": "u8" } } ], - "name": "BoardSnapshot" + "name": "MoveState" }, { "fields": [ { - "name": "member", + "name": "seed", "type": { - "type": "short_str" + "type": "u32" } }, { - "name": "cell", + "name": "x", "type": { - "name": "Scores", - "type": "record" + "type": "i32" } }, { - "name": "board", + "name": "y", "type": { - "name": "Scores", - "type": "record" + "type": "i32" } } ], - "name": "Mismatch" + "name": "PitRoomContext" }, { "fields": [ { - "name": "tier", + "name": "biome", "type": { "type": "u8" } }, { - "name": "radius", - "type": { - "type": "f32" - } - }, - { - "name": "tele", - "type": { - "type": "f32" - } - } - ], - "name": "MobTier" - }, - { - "fields": [ - { - "name": "x", - "type": { - "type": "f32" - } - }, - { - "name": "y", - "type": { - "type": "f32" - } - }, - { - "name": "vx", - "type": { - "type": "f32" - } - }, - { - "name": "vy", - "type": { - "type": "f32" - } - }, - { - "name": "dash", + "name": "danger", "type": { "type": "u8" } }, { - "name": "cd", + "name": "mobs", "type": { - "type": "u8" + "item": { + "name": "RoomMob", + "type": "record" + }, + "type": "list" } } ], - "name": "MoveState" + "name": "PitRoomLayout" }, { "fields": [ @@ -856,123 +837,104 @@ { "fields": [ { - "name": "xp", - "type": { - "type": "u64" - } - }, - { - "name": "level", + "name": "default_seed", "type": { "type": "u32" } }, { - "name": "kills", + "name": "half", "type": { - "type": "u32" + "type": "f32" } }, { - "name": "level_xp", + "name": "door_half", "type": { - "type": "u64" + "type": "f32" } }, { - "name": "next_level_xp", + "name": "max_room", "type": { - "type": "u64" + "type": "i32" } }, { - "name": "source", + "name": "biomes", "type": { - "type": "short_str" + "item": { + "type": "short_str" + }, + "type": "list" } } ], - "name": "Profile" + "name": "PitWorld" }, { "fields": [ { - "name": "backend", - "type": { - "type": "short_str" - } - }, - { - "name": "crawlers", - "type": { - "type": "u32" - } - }, - { - "name": "events", + "name": "xp", "type": { - "type": "u32" + "type": "u64" } }, { - "name": "applied", + "name": "level", "type": { "type": "u32" } }, { - "name": "duplicates", + "name": "kills", "type": { "type": "u32" } }, { - "name": "identical_top", - "type": { - "type": "bool" - } - }, - { - "name": "matches_cells", + "name": "level_xp", "type": { - "type": "bool" + "type": "u64" } }, { - "name": "mismatches", + "name": "next_level_xp", "type": { - "item": { - "name": "Mismatch", - "type": "record" - }, - "type": "list" + "type": "u64" } }, { - "name": "took_ms", + "name": "source", "type": { - "type": "f64" + "type": "short_str" } } ], - "name": "RebuildReport" + "name": "Profile" }, { "fields": [ { - "name": "kills", + "name": "tier", + "type": { + "type": "u8" + } + }, + { + "name": "x", "type": { - "type": "f64" + "type": "f32" } }, { - "name": "xp", + "name": "y", "type": { - "type": "f64" + "type": "f32" } } ], - "name": "Scores" + "name": "RoomMob" } ], "schemaVersion": 3 diff --git a/examples/game/sveltekit/src/lib/pit/Rooms.graphql b/examples/game/sveltekit/src/lib/pit/Rooms.graphql new file mode 100644 index 00000000..671a1812 --- /dev/null +++ b/examples/game/sveltekit/src/lib/pit/Rooms.graphql @@ -0,0 +1,15 @@ +# Every room the zone status read model knows (mobpit-service's +# zone_activities, projected from the rooms' zone-activity cells): the +# minimap. SSR seeds it, `@live` keeps it current as rooms wake, empty and +# sleep. `node` is a debug detail (which celld node ran the activation). +query Rooms @load @live { + zone_activities(order_by: [{ zone_id: asc }]) { + zone_id + status + players + restored + activations + node + changed_at_ms + } +} diff --git a/examples/game/sveltekit/src/lib/pit/Rooms.svelte b/examples/game/sveltekit/src/lib/pit/Rooms.svelte new file mode 100644 index 00000000..64f9e359 --- /dev/null +++ b/examples/game/sveltekit/src/lib/pit/Rooms.svelte @@ -0,0 +1,80 @@ + + +
+ {#each grid as line, j (j)} + {#each line as cell (cell.zone)} +
+ {#if cell.row && cell.row.players > 0}{cell.row.players}{/if} + {#if debug && cell.row?.node}{cell.row.node.replace(/^node-/, '')}{/if} +
+ {/each} + {/each} +
+
■ active ■ dormant ■ asleep ■ never ran
+ + diff --git a/examples/game/sveltekit/src/lib/pit/loop.ts b/examples/game/sveltekit/src/lib/pit/loop.ts index ddec7fb6..f6c75154 100644 --- a/examples/game/sveltekit/src/lib/pit/loop.ts +++ b/examples/game/sveltekit/src/lib/pit/loop.ts @@ -9,6 +9,7 @@ import { InputPacer, type GameClient } from '@hops-ops/distributed/game'; import { BUTTON, PIT_RULES, + PIT_WORLD, isMob, isPillar, isPlayer, @@ -22,6 +23,7 @@ import { const TIER_COLOR: Record = { 1: '#9ad16b', 2: '#b48cf2', 3: '#ff6b6b' }; const TIER_RADIUS = Object.fromEntries(PIT_RULES.tiers.map((t) => [t.tier, t])); const PLAYER_RADIUS = 0.45; +const DOOR_HALF = PIT_WORLD.doorHalf; /** Swing cone, from the server's rules (reach to a mob's edge, plus the body). */ const SWING_REACH = PIT_RULES.attackRange + PLAYER_RADIUS; const SWING_HALF = PIT_RULES.attackHalfAngle; @@ -49,7 +51,8 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) const steer = (): PitInput => { const hello = options.hello(); - const rendered = client.render(); + const room = client.authority?.zoneId; + const rendered = client.render().filter((e) => room === undefined || e.zoneId === room); const me = rendered.find((e) => e.id === hello?.entity && isPlayer(e)); if (options.autoHunt() && me !== undefined) { const p = playerFields(me); @@ -79,6 +82,10 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) let frame = requestAnimationFrame(function draw(now) { pacer.update(now, () => client.sendInput(steer())); const half = options.hello()?.half ?? 12; + // Only the room the player is in (during a doorway handoff the + // client briefly holds both). + const room = client.authority?.zoneId; + if (room !== undefined) canvas.dataset.room = room; const { width, height } = canvas; const scale = Math.min(width, height) / (2 * half); const at = (x: number, y: number): [number, number] => [width / 2 + x * scale, height / 2 - y * scale]; @@ -97,7 +104,23 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) ctx.fillRect(0, 0, width, height); ctx.strokeStyle = '#2b2533'; ctx.strokeRect(...at(-half, half), 2 * half * scale, 2 * half * scale); + // Doorways: the middle of each wall leads to the neighbouring room. + ctx.strokeStyle = '#4dd0e1'; + ctx.lineWidth = 4; + for (const [x0, y0, x1, y1] of [ + [half, -DOOR_HALF, half, DOOR_HALF], + [-half, -DOOR_HALF, -half, DOOR_HALF], + [-DOOR_HALF, half, DOOR_HALF, half], + [-DOOR_HALF, -half, DOOR_HALF, -half] + ]) { + ctx.beginPath(); + ctx.moveTo(...at(x0!, y0!)); + ctx.lineTo(...at(x1!, y1!)); + ctx.stroke(); + } + ctx.lineWidth = 1; for (const entity of client.render()) { + if (room !== undefined && entity.zoneId !== room) continue; if (isPillar(entity)) { const p = pillarFields(entity); circle(p.x, p.y, p.r, '#3a3443'); diff --git a/examples/game/sveltekit/src/lib/server/game.ts b/examples/game/sveltekit/src/lib/server/game.ts index d8ec1c7d..df887a76 100644 --- a/examples/game/sveltekit/src/lib/server/game.ts +++ b/examples/game/sveltekit/src/lib/server/game.ts @@ -2,10 +2,37 @@ import { env } from '$env/dynamic/private'; import type { RequestEvent } from '@sveltejs/kit'; import { createGameTicketHandler } from '@hops-ops/distributed/sveltekit/game/server'; -/** Zone admission: principal from the Auth.js session, zone chosen here. */ +/** The room every player starts in. */ +export const ENTRANCE = 'pit.0.0'; + +/** `pit..` with canonical integers (the rooms' grid). */ +export function isRoom(zone: string | undefined): zone is string { + const m = zone === undefined ? null : /^pit\.(-?\d+)\.(-?\d+)$/.exec(zone); + return m !== null && m.slice(1).every((v) => String(Number(v)) === v && Math.abs(Number(v)) <= 1_000_000); +} + +/** + * Room admission: principal from the Auth.js session, the room chosen here — + * the entrance for a fresh join; the room the client asks for when it + * rejoins one (a room that restarted closes sockets with 1012, a reload). + * Moving between rooms needs no ticket from here: a doorway hands the player + * over with a transfer ticket the room itself signs. + */ export const tickets = createGameTicketHandler({ secret: () => env.GAME_TICKET_SECRET, getSession: (event: RequestEvent) => event.locals.auth(), - // One shared pit; a real game picks the zone the player is in or owns. - zone: () => env.PIT_ZONE ?? 'pit-1' + zone: ({ requested }) => (isRoom(requested) ? requested : ENTRANCE) }); + +/** + * Where browsers reach the rooms: celld's public listeners (`ws://…`, + * comma separated for a local fleet; a load balancer in production). The + * page rotates over them per connection — an entry point, never a + * placement: celld forwards every socket to the node that runs the room. + */ +export function roomsUrls(): string[] { + return (env.GAME_ZONES_URL ?? 'ws://127.0.0.1:2970') + .split(',') + .map((u) => u.trim()) + .filter((u) => u.length > 0); +} diff --git a/examples/game/sveltekit/src/routes/pit/+page.server.ts b/examples/game/sveltekit/src/routes/pit/+page.server.ts index e987cd5f..bdc48211 100644 --- a/examples/game/sveltekit/src/routes/pit/+page.server.ts +++ b/examples/game/sveltekit/src/routes/pit/+page.server.ts @@ -1,8 +1,7 @@ import { redirect } from '@sveltejs/kit'; -import { env } from '$env/dynamic/private'; import { loadDistributedLeaderboard } from '@hops-ops/distributed/sveltekit'; import { XpLeaderboardLeaderboard, type XpLeaderboardData } from '$distributed'; -import { tickets } from '$lib/server/game'; +import { roomsUrls, tickets } from '$lib/server/game'; import { graphqlUrl } from '$lib/server/distributed'; export const load = async (event) => { @@ -23,7 +22,7 @@ export const load = async (event) => { } return { zone: joined.zone, - zonesUrl: env.GAME_ZONES_URL ?? 'ws://127.0.0.1:2967', + roomsUrls: roomsUrls(), principal: joined.principal, board }; diff --git a/examples/game/sveltekit/src/routes/pit/+page.svelte b/examples/game/sveltekit/src/routes/pit/+page.svelte index f3d8fde8..031f0025 100644 --- a/examples/game/sveltekit/src/routes/pit/+page.svelte +++ b/examples/game/sveltekit/src/routes/pit/+page.svelte @@ -3,16 +3,20 @@ import { createMobpitClient, EVENTS } from '$lib/generated/mobpit/index.ts'; import Leaderboard from '$lib/pit/Leaderboard.svelte'; import MyStats from '$lib/pit/MyStats.svelte'; + import Rooms from '$lib/pit/Rooms.svelte'; import { startPitLoop } from '$lib/pit/loop'; let { data } = $props(); - // Client built on mount (browser only), joins the server-chosen zone, + // Client built on mount (browser only), joins the server-chosen room, // closed on navigation. Tickets come from /api/game/ticket (session). + // Rooms are celld objects: any entry point reaches any room (celld + // forwards); the page rotates over the entry points it was given. + let entry = 0; const game = useGame( () => createMobpitClient({ - url: (zone) => `${data.zonesUrl}/zones/${encodeURIComponent(zone)}`, + url: (zone) => `${data.roomsUrls[entry++ % data.roomsUrls.length]}/rooms/${encodeURIComponent(zone)}`, ticket: gameTicketSource(), pingIntervalMs: 500, inputDtMs: 1000 / 30 @@ -22,7 +26,43 @@ const hello = game.latest('hello'); const kills = game.events('kill', { limit: 20 }); const saved = game.events('saved', { limit: 20 }); - game.on('hello', (h) => game.client?.setControlledEntity(h.entity)); + game.on('hello', (h) => { + game.client?.setControlledEntity(h.entity); + if (h.restored) toast(`pit.${h.roomX}.${h.roomY}: restored from hibernation (${h.roomKills} kills here so far)`); + }); + + // Toasts (room restores, sleeps). + let toasts = $state<{ id: number; text: string }[]>([]); + let toastSeq = 0; + function toast(text: string) { + const id = ++toastSeq; + toasts = [...toasts, { id, text }]; + setTimeout(() => (toasts = toasts.filter((t) => t.id !== id)), 6000); + } + + // Debug overlay: the room object's own view (which celld node runs it — + // celld decides; this only shows it). + let debug = $state(true); + type RoomInfo = { zone: string; node: string; isolate: string; activation: string; wakes: number; restored: boolean; players: number; hz: number; lateMs: { p50: number; p99: number } }; + let info = $state(); + $effect(() => { + const zone = game.zone; + if (!debug || zone === undefined) return; + const http = (data.roomsUrls[0] ?? '').replace(/^ws/, 'http'); + let stop = false; + const poll = async () => { + while (!stop) { + try { + info = await (await fetch(`${http}/rooms/${encodeURIComponent(zone)}/info`)).json(); + } catch { + // the room's node is moving; keep the last view + } + await new Promise((r) => setTimeout(r, 1000)); + } + }; + void poll(); + return () => (stop = true); + }); let canvas = $state(); let autoHunt = $state(false); @@ -43,12 +83,26 @@ +
+ {#each toasts as t (t.id)}
{t.text}
{/each} +
- - - -

- - - diff --git a/examples/game/zone-do-spike/run.sh b/examples/game/zone-do-spike/run.sh deleted file mode 100755 index 3db39e0c..00000000 --- a/examples/game/zone-do-spike/run.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env bash -# Spike gr-p42: Mob Pit rooms as celld Durable Objects on a local -# multi-node celld fleet (production mode, one shared S3 bucket). -# -# ./run.sh up [N] S3 container + bucket, build, deploy, N nodes (default 3) -# ./run.sh deploy rebuild and publish a new version (nodes adopt it live) -# ./run.sh nodes [N] start N nodes ./run.sh kill SIGKILL one node -# ./run.sh status per-node owned/resident cells (operator view) -# ./run.sh down stop nodes, remove the S3 container -# -# Nodes: node-a/b/c, public 127.0.0.1:18101-18103 (open any of them in a -# browser: /?room=pit-1&name=ana), operator 127.0.0.1:18201-18203. -# Needs: CELLD (celld >= 0.6.2), worker-build, esbuild (CELLD_ESBUILD), docker. -set -euo pipefail -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -STATE="${SPIKE_STATE:-$HERE/.run}" -CELLD="${CELLD:-celld}" -export CELLD_ESBUILD="${CELLD_ESBUILD:-$HERE/../client/node_modules/.bin/esbuild}" -export AWS_ACCESS_KEY_ID=spikeadmin AWS_SECRET_ACCESS_KEY=spikesecret123 AWS_REGION=us-east-1 -S3_PORT="${S3_PORT:-19400}" -export S3_ENDPOINT="http://127.0.0.1:$S3_PORT" -BUCKET="s3://zone-spike/zone" -NAMES=(a b c d) -mkdir -p "$STATE" - -s3_up() { - S3_PORT="$S3_PORT" docker compose -p zone-do-spike -f "$HERE/tools/s3-compose.yaml" up -d >/dev/null 2>&1 - for _ in $(seq 1 30); do curl -s -o /dev/null "$S3_ENDPOINT/" && break; sleep 1; done - aws --endpoint-url "$S3_ENDPOINT" s3 mb s3://zone-spike >/dev/null 2>&1 || true -} - -build() { - (cd "$HERE" && worker-build --release >/dev/null) - "$CELLD_ESBUILD" "$HERE/page/main.ts" --bundle --format=esm --target=es2022 \ - --outfile="$HERE/public/page.js" --log-level=warning -} - -deploy() { - build - (cd "$HERE" && "$CELLD" deploy . --bucket "$BUCKET" 2>&1 | grep -E "Version ID|rror") -} - -nodes() { - local n="${1:-3}" - for i in $(seq 0 $((n - 1))); do - local nm=${NAMES[$i]} dir="$STATE/node-${NAMES[$i]}" - mkdir -p "$dir" - [[ -f "$dir/pid" ]] && kill -0 "$(cat "$dir/pid")" 2>/dev/null && continue - CELLD_NODE="node-$nm" CELLD_WATCH="$dir/watch" RUST_LOG="${RUST_LOG:-warn}" \ - CELLD_IDLE_EVICT_S="${CELLD_IDLE_EVICT_S:-10}" \ - nohup "$CELLD" --bucket "$BUCKET" --listen "127.0.0.1:$((18101 + i))" \ - --internal-listen "127.0.0.1:$((18201 + i))" --advertise "127.0.0.1:$((18201 + i))" \ - >"$dir/celld.log" 2>&1 & - echo $! >"$dir/pid" - echo "node-$nm: http://127.0.0.1:$((18101 + i))/ (operator :$((18201 + i)), pid $!)" - done - for i in $(seq 0 $((n - 1))); do - for _ in $(seq 1 60); do curl -fs "http://127.0.0.1:$((18101 + i))/ticket" >/dev/null && break; sleep 0.5; done - done -} - -status() { - for i in 0 1 2 3; do - curl -s -m 2 "http://127.0.0.1:$((18201 + i))/state" 2>/dev/null | python3 -c ' -import json,sys -d=json.load(sys.stdin); l=d["node_load"] -print(sys.argv[1], "owned", d["owned_cells"], "resident", l["resident_cells"], "cpu%", l["cpu_percent_x100"]/100, "rebalanced", d["rebalanced"], "version", d["deployment"]["version"])' "node-${NAMES[$i]}" 2>/dev/null || true - done -} - -down() { - for nm in "${NAMES[@]}"; do - local f="$STATE/node-$nm/pid" - [[ -f $f ]] && { kill "$(cat "$f")" 2>/dev/null || true; rm -f "$f"; } - done - docker compose -p zone-do-spike -f "$HERE/tools/s3-compose.yaml" down -v >/dev/null 2>&1 || true -} - -case "${1:-}" in -up) s3_up; deploy; nodes "${2:-3}" ;; -deploy) deploy ;; -nodes) nodes "${2:-3}" ;; -kill) kill -9 "$(cat "$STATE/node-$2/pid")"; rm -f "$STATE/node-$2/pid"; echo "killed node-$2" ;; -status) status ;; -down) down ;; -*) sed -n '2,13p' "$0"; exit 1 ;; -esac diff --git a/examples/game/zone-do-spike/src/lib.rs b/examples/game/zone-do-spike/src/lib.rs deleted file mode 100644 index 8ae06578..00000000 --- a/examples/game/zone-do-spike/src/lib.rs +++ /dev/null @@ -1,755 +0,0 @@ -//! Spike gr-p42: a live Mob Pit room as a celld Durable Object. -//! -//! - `GET /ticket?room=&name=` (dev only): a signed join ticket, the same -//! HS256 format the native zone server admits (`distributed_game::ticket`). -//! - `GET /rooms/{room}` (WebSocket upgrade): the room object named `room`. -//! The client speaks the existing `distributed_game` wire protocol: Hello -//! with the ticket, then Input / Ping / Leave; the room answers Welcome, -//! Delta, Event, Pong. -//! - `GET /rooms/{room}/stats`: tick timing numbers of the room (JSON). -//! - `GET /placement?rooms=a,b`: which node owns each room (operator view). -//! - everything else: the static debug page (`public/`). -//! -//! The room (`PitRoom`) runs a `ZoneHost` in the object's memory. It -//! ticks with a `setTimeout` loop while any player is in it, writes its -//! hibernation snapshot to object storage every few seconds and when the -//! last player leaves, then stops; celld may then evict it. The next join -//! reactivates it on whichever node owns it and restores the snapshot. -//! -//! celld does not tell application code which node runs it, so the room -//! asks every node's operator listener (`NODES`) whether it holds the room. - -mod pit; - -use std::cell::RefCell; -use std::collections::{HashMap, HashSet, VecDeque}; -use std::rc::Rc; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::Duration; - -use distributed_game::protocol::{error, ClientMessage, ServerMessage, PROTOCOL_VERSION}; -use distributed_game::ticket::TicketKey; -use distributed_game::{Clock, HostConfig, Outbound, Role, TicketClaims, ZoneHost}; -use pit::Pit; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use worker::*; - -const SNAPSHOT_KEY: &str = "pit-snapshot"; -const PERSIST_EVERY_MS: f64 = 5_000.0; - -// ------------------------------------------------------------------ time - -fn performance_now() -> f64 { - let global = js_sys::global(); - js_sys::Reflect::get(&global, &"performance".into()) - .ok() - .and_then(|p| { - let now = js_sys::Reflect::get(&p, &"now".into()).ok()?; - let now: js_sys::Function = now.into(); - now.call0(&p).ok()?.as_f64() - }) - .unwrap_or_else(js_sys::Date::now) -} - -/// Monotonic clock over `Date.now()` (`Instant` panics on wasm32). -struct JsClock { - origin_ms: f64, - last_us: AtomicU64, -} - -impl JsClock { - fn new() -> Self { - Self { - origin_ms: js_sys::Date::now(), - last_us: AtomicU64::new(0), - } - } -} - -impl Clock for JsClock { - fn now(&self) -> Duration { - let us = ((js_sys::Date::now() - self.origin_ms).max(0.0) * 1000.0) as u64; - let us = self.last_us.fetch_max(us, Ordering::Relaxed).max(us); - Duration::from_micros(us) - } - - fn unix_ms(&self) -> u64 { - self.origin_ms as u64 + self.now().as_millis() as u64 - } -} - -fn random_id() -> String { - format!( - "{:08x}", - (js_sys::Math::random() * f64::from(u32::MAX)) as u32 - ) -} - -thread_local! { - /// One id per V8 isolate (module instance). - static ISOLATE: String = random_id(); - /// Rooms alive in this isolate. - static ISOLATE_ROOMS: RefCell> = RefCell::new(HashSet::new()); -} - -// ------------------------------------------------------------------ stats - -/// Loop timing over the life of an activation. -#[derive(Default)] -struct TickStats { - /// How late each wake-up was against its deadline (ms). - late: VecDeque, - /// Interval between consecutive loop iterations (ms). - interval: VecDeque, - /// Work per iteration (ms; advance + sends). - work: VecDeque, - /// Fixed steps dropped by the catch-up cap. - dropped_ticks: u64, - /// Iterations that ran more than one fixed step (catch-up). - catch_up: u64, - iterations: u64, - last_wake: Option, - late_ema: f64, - work_ema: f64, - late_max_window: f64, - window: u32, - late_max: f64, -} - -const STAT_WINDOW: usize = 20_000; - -fn push(q: &mut VecDeque, v: f64) { - if q.len() == STAT_WINDOW { - q.pop_front(); - } - q.push_back(v); -} - -fn pct(q: &VecDeque, p: f64) -> f64 { - if q.is_empty() { - return 0.0; - } - let mut v: Vec = q.iter().copied().collect(); - v.sort_by(|a, b| a.total_cmp(b)); - v[((v.len() - 1) as f64 * p).round() as usize] -} - -// ------------------------------------------------------------------ room - -#[derive(Serialize, Deserialize)] -struct Attachment { - client: u32, - activation: String, -} - -struct Room { - name: String, - id_hex: String, - activation: String, - constructed_ms: f64, - host: Option>, - sockets: HashMap, - ticking: bool, - hz: u32, - key: TicketKey, - nonces: HashSet, - stats: TickStats, - first_tick_ms: Option, - restored: bool, - node: String, - welcome_ms: Vec, -} - -impl Room { - fn flush(&mut self) { - let Some(host) = self.host.as_mut() else { - return; - }; - for out in host.drain_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")); - } - } - } - } - } - - fn occupied(&self) -> bool { - self.host - .as_ref() - .is_some_and(|h| h.player_count() > 0 || h.client_count() > 0) - } - - fn snapshot(&self) -> Option { - let state = self.host.as_ref()?.hibernate_state()?; - String::from_utf8(state).ok() - } - - fn sync_host_info(&mut self) { - let isolate_rooms = ISOLATE_ROOMS.with(|r| r.borrow().len()) as u16; - let (late, late_max, work) = ( - self.stats.late_ema, - self.stats.late_max, - self.stats.work_ema, - ); - let wake_ms = self - .first_tick_ms - .map(|t| (t - self.constructed_ms).max(0.0) as u32) - .unwrap_or(0); - let (node, restored, hz) = (self.node.clone(), self.restored, self.hz as u8); - if let Some(host) = self.host.as_mut() { - let zone = host.zone_mut(); - let activations = zone.activations; - let info = &mut zone.host; - info.node = node; - info.restored = restored; - info.activations = activations; - info.hz = hz; - info.late_ms = late as f32; - info.late_max_ms = late_max as f32; - info.work_us = (work * 1000.0) as u32; - info.wake_ms = wake_ms; - info.isolate_rooms = isolate_rooms; - } - } -} - -#[durable_object] -pub struct PitRoom { - state: State, - env: Env, - room: Rc>, -} - -impl DurableObject for PitRoom { - fn new(state: State, env: Env) -> Self { - console_error_panic_hook::set_once(); - let constructed_ms = performance_now(); - let id_hex = state.id().to_string(); - let name = state.id().name().unwrap_or_default(); - let hz = env - .var("TICK_HZ") - .ok() - .and_then(|v| v.to_string().parse().ok()) - .unwrap_or(30); - let secret = env - .var("TICKET_SECRET") - .map(|v| v.to_string()) - .unwrap_or_default(); - let room = Room { - name, - id_hex, - activation: random_id(), - constructed_ms, - host: None, - sockets: HashMap::new(), - ticking: false, - hz, - key: TicketKey::new(secret.into_bytes()), - nonces: HashSet::new(), - stats: TickStats::default(), - first_tick_ms: None, - restored: false, - node: "?".into(), - welcome_ms: Vec::new(), - }; - Self { - state, - env, - room: Rc::new(RefCell::new(room)), - } - } - - async fn fetch(&self, req: Request) -> Result { - let url = req.url()?; - if let Some(name) = req.headers().get("x-room")? { - let mut room = self.room.borrow_mut(); - if room.name.is_empty() { - room.name = name; - } - } - self.ensure_host().await?; - if url.path().ends_with("/stats") { - if url.query_pairs().any(|(k, _)| k == "reset") { - self.room.borrow_mut().stats = TickStats::default(); - } - return Response::from_json(&self.stats_json()); - } - if req.headers().get("upgrade")?.as_deref() != Some("websocket") { - return Response::error("expected a WebSocket upgrade", 426); - } - let pair = WebSocketPair::new()?; - self.state.accept_web_socket(&pair.server); - Response::from_websocket(pair.client) - } - - async fn websocket_message( - &self, - ws: WebSocket, - message: WebSocketIncomingMessage, - ) -> Result<()> { - let WebSocketIncomingMessage::Binary(bytes) = message else { - let _ = ws.close(Some(1003), Some("binary frames only")); - return Ok(()); - }; - self.ensure_host().await?; - let msg = match ClientMessage::decode(&bytes) { - Ok(m) => m, - Err(_) => { - reject(&ws, error::BAD_MESSAGE, "bad message"); - return Ok(()); - } - }; - let attached: Option = ws.deserialize_attachment().ok().flatten(); - let activation = self.room.borrow().activation.clone(); - let client = match attached { - Some(a) if a.activation == activation => Some(a.client), - // The socket outlived the activation that admitted it (the room - // was evicted and woke up again): its player is gone. - Some(_) => { - let _ = ws.close(Some(1012), Some("room restarted; rejoin")); - return Ok(()); - } - None => None, - }; - match (client, msg) { - (None, ClientMessage::Hello(hello)) => { - let t0 = performance_now(); - if hello.protocol_version != PROTOCOL_VERSION { - reject(&ws, error::VERSION_MISMATCH, "protocol version"); - return Ok(()); - } - let joined = { - let mut room = self.room.borrow_mut(); - let now = (js_sys::Date::now() / 1000.0) as u64; - let claims = match room.key.verify(&hello.ticket, now) { - Ok(c) => c, - Err(e) => { - drop(room); - reject(&ws, error::UNAUTHORIZED, &format!("{e:?}")); - return Ok(()); - } - }; - if claims.zone != room.name || !room.nonces.insert(claims.nonce.clone()) { - drop(room); - reject(&ws, error::UNAUTHORIZED, "wrong room or replayed ticket"); - return Ok(()); - } - room.sync_host_info(); - let host = room.host.as_mut().expect("host loaded"); - match host.join(&claims, hello.capabilities, &hello.content_version) { - Ok(joined) => { - let client = joined.client.0; - ws.serialize_attachment(Attachment { - client, - activation: activation.clone(), - })?; - room.sockets.insert(client, ws.clone()); - room.flush(); - let elapsed = performance_now() - t0; - room.welcome_ms.push(elapsed); - Ok(()) - } - Err(e) => Err(e), - } - }; - match joined { - Ok(()) => self.start_loop(), - Err(e) => reject(&ws, e.code, &e.message), - } - } - ( - Some(client), - ClientMessage::Input { - seq, - client_tick, - payload, - }, - ) => { - let mut room = self.room.borrow_mut(); - if let Some(host) = room.host.as_mut() { - host.push_input( - distributed_game::ClientId(client), - seq, - client_tick, - &payload, - ); - } - } - (_, ClientMessage::Ping { client_time }) => { - let room = self.room.borrow(); - let server_time = room.host.as_ref().map_or(0.0, |h| h.server_time_ms()); - let pong = ServerMessage::Pong { - client_time, - server_time, - } - .encode(&()) - .expect("pong encodes"); - let _ = ws.send_with_bytes(pong); - } - (Some(client), ClientMessage::Leave) => { - let mut room = self.room.borrow_mut(); - room.sockets.remove(&client); - if let Some(host) = room.host.as_mut() { - host.disconnect(distributed_game::ClientId(client), true); - } - room.flush(); - let _ = ws.close(Some(1000), Some("left")); - } - _ => reject(&ws, error::BAD_MESSAGE, "unexpected message"), - } - Ok(()) - } - - async fn websocket_close( - &self, - ws: WebSocket, - _code: usize, - _reason: String, - _was_clean: bool, - ) -> Result<()> { - self.drop_socket(&ws); - Ok(()) - } - - async fn websocket_error(&self, ws: WebSocket, _error: Error) -> Result<()> { - self.drop_socket(&ws); - Ok(()) - } -} - -fn reject(ws: &WebSocket, code: u32, message: &str) { - if let Ok(frame) = (ServerMessage::Error { - code, - message: message.into(), - }) - .encode(&()) - { - let _ = ws.send_with_bytes(frame); - } - let _ = ws.close(Some(1008), Some(message)); -} - -impl PitRoom { - fn drop_socket(&self, ws: &WebSocket) { - let attached: Option = ws.deserialize_attachment().ok().flatten(); - let mut room = self.room.borrow_mut(); - if let Some(a) = attached.filter(|a| a.activation == room.activation) { - room.sockets.remove(&a.client); - if let Some(host) = room.host.as_mut() { - host.disconnect(distributed_game::ClientId(a.client), false); - } - } - } - - /// Create the zone host on first use: restore the stored snapshot and - /// find out which node this is. - async fn ensure_host(&self) -> Result<()> { - if self.room.borrow().host.is_some() { - return Ok(()); - } - let stored: Option = self.state.storage().get(SNAPSHOT_KEY).await?; - let mut room = self.room.borrow_mut(); - if room.host.is_some() { - return Ok(()); - } - // `name@20` runs the room at 20 Hz (measurements). - if let Some(hz) = room - .name - .split_once('@') - .and_then(|(_, hz)| hz.parse().ok()) - { - room.hz = hz; - } - let config = HostConfig { - tick_rate: room.hz, - patch_rate: room.hz.min(20), - // The Durable Object stops ticking when empty instead. - idle_tick_rate: None, - boot_epoch: (js_sys::Date::now() * 1000.0) as u64, - reconnect_grace: Duration::from_secs(5), - ..HostConfig::default() - }; - let name = room.name.clone(); - let mut host = ZoneHost::::new(name.clone(), config, Arc::new(JsClock::new()), ()) - .with_token_source(|| format!("{}{}", random_id(), random_id())); - if let Some(state) = stored { - host.restore(state.as_bytes(), ""); - room.restored = true; - } - { - let activation = room.activation.clone(); - let info = &mut host.zone_mut().host; - info.activation = activation; - info.isolate = ISOLATE.with(|i| i.clone()); - } - room.host = Some(host); - ISOLATE_ROOMS.with(|r| r.borrow_mut().insert(name)); - drop(room); - self.find_node(); - Ok(()) - } - - /// Ask each node's operator listener whether it holds this room. - fn find_node(&self) { - let nodes = self - .env - .var("NODES") - .map(|v| v.to_string()) - .unwrap_or_default(); - let room = self.room.clone(); - let id_hex = room.borrow().id_hex.clone(); - wasm_bindgen_futures::spawn_local(async move { - for entry in nodes.split(',').filter(|e| !e.is_empty()) { - let Some((node, addr)) = entry.split_once('=') else { - continue; - }; - let Ok(mut resp) = Fetch::Url( - format!("http://{addr}/state") - .parse() - .expect("operator url"), - ) - .send() - .await - else { - continue; - }; - let Ok(state) = resp.json::().await else { - continue; - }; - let mine = state["deployment"]["cells"] - .as_object() - .is_some_and(|cells| cells.keys().any(|k| k.ends_with(&id_hex))); - if mine { - let mut room = room.borrow_mut(); - room.node = node.to_string(); - room.sync_host_info(); - return; - } - } - }); - } - - fn start_loop(&self) { - { - let mut room = self.room.borrow_mut(); - if room.ticking { - return; - } - room.ticking = true; - } - let room = self.room.clone(); - let storage = self.state.storage(); - wasm_bindgen_futures::spawn_local(async move { - let step_ms = 1000.0 / f64::from(room.borrow().hz); - let start = performance_now(); - let mut k: u64 = 1; - let mut last_persist = start; - loop { - let deadline = start + k as f64 * step_ms; - let wait = (deadline - performance_now()).max(0.0); - Delay::from(Duration::from_micros((wait * 1000.0) as u64)).await; - let woke = performance_now(); - // Skip deadlines we already missed (the fixed timestep - // catches up with several steps in one advance). - k = (((woke - start) / step_ms).floor() as u64 + 1).max(k + 1); - let (stop, snapshot) = { - let mut room = room.borrow_mut(); - let late = (woke - deadline).max(0.0); - let interval = room.stats.last_wake.map(|l| woke - l); - let report = room.host.as_mut().map(|h| h.advance()); - if room.first_tick_ms.is_none() { - room.first_tick_ms = Some(woke); - } - room.flush(); - let work = performance_now() - woke; - let s = &mut room.stats; - s.iterations += 1; - s.last_wake = Some(woke); - push(&mut s.late, late); - if let Some(i) = interval { - push(&mut s.interval, i); - } - push(&mut s.work, work); - if let Some(r) = &report { - s.dropped_ticks += u64::from(r.dropped_ticks); - if r.ticks > 1 { - s.catch_up += 1; - } - } - s.late_ema = if s.iterations == 1 { - late - } else { - s.late_ema * 0.95 + late * 0.05 - }; - s.work_ema = if s.iterations == 1 { - work - } else { - s.work_ema * 0.95 + work * 0.05 - }; - s.late_max_window = s.late_max_window.max(late); - s.window += 1; - if s.window >= room.hz { - let s = &mut room.stats; - s.late_max = s.late_max_window; - s.late_max_window = 0.0; - s.window = 0; - } - room.sync_host_info(); - let stop = !room.occupied(); - let persist = stop || woke - last_persist >= PERSIST_EVERY_MS; - if stop { - room.ticking = false; - } - (stop, persist.then(|| room.snapshot()).flatten()) - }; - if let Some(snapshot) = snapshot { - last_persist = woke; - if let Err(e) = storage.put(SNAPSHOT_KEY, snapshot).await { - console_error!("persist failed: {e}"); - } - } - if stop { - return; - } - } - }); - } - - fn stats_json(&self) -> serde_json::Value { - let room = self.room.borrow(); - let s = &room.stats; - let host = room.host.as_ref(); - json!({ - "room": room.name, - "node": room.node, - "isolate": ISOLATE.with(|i| i.clone()), - "activation": room.activation, - "restored": room.restored, - "hz": room.hz, - "ticking": room.ticking, - "players": host.map_or(0, |h| h.player_count()), - "tick": host.map_or(0, |h| h.tick()), - "lifetime_ticks": host.map_or(0, |h| h.zone().ticks()), - "kills": host.map_or(0, |h| h.zone().kills), - "activations": host.map_or(0, |h| h.zone().activations), - "isolate_rooms": ISOLATE_ROOMS.with(|r| r.borrow().len()), - "wake_ms": room.first_tick_ms.map(|t| t - room.constructed_ms), - "welcome_ms": room.welcome_ms, - "iterations": s.iterations, - "dropped_ticks": s.dropped_ticks, - "catch_up_iterations": s.catch_up, - "late_ms": {"p50": pct(&s.late, 0.5), "p99": pct(&s.late, 0.99), "max": pct(&s.late, 1.0)}, - "interval_ms": {"p50": pct(&s.interval, 0.5), "p99": pct(&s.interval, 0.99), "max": pct(&s.interval, 1.0)}, - "work_ms": {"p50": pct(&s.work, 0.5), "p99": pct(&s.work, 0.99), "max": pct(&s.work, 1.0)}, - }) - } -} - -// ------------------------------------------------------------------ worker - -fn ticket(env: &Env, room: &str, name: &str) -> Result { - let secret = env.var("TICKET_SECRET")?.to_string(); - let key = TicketKey::new(secret.into_bytes()); - let exp = (js_sys::Date::now() / 1000.0) as u64 + 300; - let principal = format!("guest:{name}"); - let claims = TicketClaims::new( - principal, - room, - exp, - random_id() + &random_id(), - Role::Player, - ) - .with_name(name); - Ok(key.sign(&claims)) -} - -async fn placement(env: &Env, rooms: &str) -> Result { - let ns = env.durable_object("PIT_ROOM")?; - let mut ids = Vec::new(); - for room in rooms.split(',').filter(|r| !r.is_empty()) { - ids.push((room.to_string(), ns.id_from_name(room)?.to_string())); - } - let nodes = env.var("NODES")?.to_string(); - let mut out = serde_json::Map::new(); - let mut node_rows = Vec::new(); - for entry in nodes.split(',').filter(|e| !e.is_empty()) { - let Some((node, addr)) = entry.split_once('=') else { - continue; - }; - let state = match Fetch::Url(format!("http://{addr}/state").parse()?) - .send() - .await - { - Ok(mut r) => r.json::().await.ok(), - Err(_) => None, - }; - let Some(state) = state else { - node_rows.push(json!({"node": node, "up": false})); - continue; - }; - let cells = state["deployment"]["cells"] - .as_object() - .cloned() - .unwrap_or_default(); - for (room, id) in &ids { - if cells.keys().any(|k| k.ends_with(id.as_str())) { - out.insert(room.clone(), json!(node)); - } - } - node_rows.push(json!({ - "node": node, - "up": true, - "resident_cells": state["node_load"]["resident_cells"], - "owned_cells": state["owned_cells"], - "cpu_percent": state["node_load"]["cpu_percent_x100"].as_f64().map(|c| c / 100.0), - "rebalanced": state["rebalanced"], - })); - } - Response::from_json(&json!({"rooms": out, "nodes": node_rows})) -} - -#[event(fetch)] -async fn main(req: Request, env: Env, _ctx: Context) -> Result { - console_error_panic_hook::set_once(); - let url = req.url()?; - let path = url.path().to_string(); - let query: HashMap = url.query_pairs().into_owned().collect(); - if path == "/ticket" { - let room = query.get("room").cloned().unwrap_or_else(|| "pit-1".into()); - let name = query.get("name").cloned().unwrap_or_else(|| "guest".into()); - return Response::from_json(&json!({"ticket": ticket(&env, &room, &name)?})); - } - if path == "/placement" { - return placement(&env, query.get("rooms").map_or("", |s| s.as_str())).await; - } - if let Some(rest) = path.strip_prefix("/rooms/") { - let raw = rest.split('/').next().unwrap_or_default(); - let room: String = js_sys::decode_uri_component(raw) - .map(String::from) - .unwrap_or_else(|_| raw.to_string()); - if room.is_empty() { - return Response::error("room required", 400); - } - let ns = env.durable_object("PIT_ROOM")?; - let stub = ns.id_from_name(&room)?.get_stub()?; - // Name the room for the object (its id has no name on some paths). - let headers = req.headers().clone(); - headers.set("x-room", &room)?; - let mut init = RequestInit::new(); - init.with_method(req.method()).with_headers(headers); - let forwarded = Request::new_with_init(req.url()?.as_str(), &init)?; - return stub.fetch_with_request(forwarded).await; - } - let assets = env.assets("ASSETS")?; - assets.fetch_request(req).await -} diff --git a/examples/game/zone-do-spike/src/pit.rs b/examples/game/zone-do-spike/src/pit.rs deleted file mode 100644 index b7ae1ac1..00000000 --- a/examples/game/zone-do-spike/src/pit.rs +++ /dev/null @@ -1,859 +0,0 @@ -//! Mob Pit, trimmed for the spike: the same players, mobs, pillars, melee -//! combat and the same replicated kinds/ids as -//! `examples/game/server/src/mobpit/zone.rs` (copied, not shared: that zone -//! lives in the native `game_demos` crate next to tokio, reqwest and the -//! Crawler fact sink, so it does not build for wasm32 as is). -//! -//! Left out: kill facts / Crawler XP settlement, leaderboard, profiles. -//! Added: the global `host` kind (which celld node, isolate and activation -//! run the room, tick numbers) and a hibernation snapshot (mobs, kill -//! counts) that the Durable Object keeps in its storage. - -use std::collections::BTreeMap; - -use distributed_game::{ - schema, ConsumePolicy, Ctx, Entities, Entity, EntityId, EntitySchema, GameEvent, GameInput, - LeaveReason, ObserverId, PlayerId, Project, Replicated, View, Zone, -}; -use game_sim::arena::{self as movement, MoveState, PILLARS, PLAYER_RADIUS}; -pub use game_sim::mobpit::{pit_step, PitInput}; -use serde::{Deserialize, Serialize}; - -/// Entity id of the zone's HUD view (as in Mob Pit). -pub const PIT_ENTITY: EntityId = EntityId(900); -/// Entity id of the host view (spike only). -pub const HOST_ENTITY: EntityId = EntityId(901); - -/// A player (Mob Pit's `player` kind, id 1). -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "player", id = 1, priority = 2.0, of = Player)] -pub struct PlayerView { - #[field_type(movement::POS)] - #[correct(ease = 150)] - #[from(state.x)] - pub x: f32, - #[field_type(movement::POS)] - #[correct(ease = 150)] - #[from(state.y)] - pub y: f32, - #[field_type(movement::VEL)] - #[correct(snap)] - #[from(state.vx)] - pub vx: f32, - #[field_type(movement::VEL)] - #[correct(snap)] - #[from(state.vy)] - pub vy: f32, - #[correct(snap)] - #[from(state.dash)] - pub dash: u8, - #[correct(snap)] - #[from(state.cd)] - pub cd: u8, - #[priority(0.2)] - #[correct(replace)] - #[from] - pub name: String, - #[correct(replace)] - pub hp: u8, - #[angle(bits = 8)] - #[correct(replace)] - #[from] - pub face: f32, - #[correct(replace)] - pub swing: u8, - /// Kills by this player in this room (persisted with the room). - #[correct(replace)] - pub level: u16, - #[correct(replace)] - pub dead: bool, -} - -/// A mob (Mob Pit's `mob` kind, id 2). -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "mob", id = 2, of = Mob)] -pub struct MobView { - #[quant(min = -movement::HALF, max = movement::HALF, bits = 14)] - #[from] - pub x: f32, - #[quant(min = -movement::HALF, max = movement::HALF, bits = 14)] - #[from] - pub y: f32, - #[angle(bits = 8)] - #[from] - pub face: f32, - #[from] - pub tier: u8, - pub hp: u8, - pub state: u8, - pub tele: u8, -} - -/// A pillar (the Arena's `pillar` kind, id 3). -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "pillar", id = 3, global, of = Pillar)] -pub struct PillarView { - #[from] - pub x: f32, - #[from] - pub y: f32, - #[from] - pub r: f32, -} - -/// Room numbers (global). -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "pit", id = 4, global, of = Pit)] -pub struct PitView { - /// Kills in this room since it was first created (persisted). - pub kills: u32, - /// Ticks this room has simulated across all activations (persisted). - pub ticks: u32, -} - -/// Where the room runs (global, spike only): set by the Durable Object. -#[derive(Debug, Clone, PartialEq, Replicated)] -#[replicated(kind = "host", id = 5, global, of = HostInfo)] -pub struct HostView { - /// celld node id (`CELLD_NODE`), found through the operator listeners. - #[from] - pub node: String, - /// Random id of the V8 isolate (module instance) running the room. - #[from] - pub isolate: String, - /// Random id of this activation of the object (new on every wake). - #[from] - pub activation: String, - /// Activations of this room so far (persisted). - #[from] - pub activations: u32, - /// The room was restored from storage on this activation. - #[from] - pub restored: bool, - /// Cold start: object constructor to first tick (ms). - #[from] - pub wake_ms: u32, - /// Tick rate (Hz). - #[from] - pub hz: u8, - /// Loop wake lateness EMA (ms; how late the timer fired). - #[from] - pub late_ms: f32, - /// Max lateness over the last second (ms). - #[from] - pub late_max_ms: f32, - /// Work per loop iteration EMA (µs: advance + sends). - #[from] - pub work_us: u32, - /// Rooms resident in the same isolate. - #[from] - pub isolate_rooms: u16, -} - -/// A static pillar (the Arena's floor). -pub struct Pillar { - id: EntityId, - pub x: f32, - pub y: f32, - pub r: f32, -} - -impl Pillar { - fn all() -> Vec { - 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 - } -} - -/// The host info the Durable Object keeps current. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct HostInfo { - pub node: String, - pub isolate: String, - pub activation: String, - pub activations: u32, - pub restored: bool, - pub wake_ms: u32, - pub hz: u8, - pub late_ms: f32, - pub late_max_ms: f32, - pub work_us: u32, - pub isolate_rooms: u16, -} - -impl Entity for HostInfo { - fn entity_id(&self) -> EntityId { - HOST_ENTITY - } -} - -/// Mob Pit's events (ids as in Mob Pit; `Kill` trimmed). -#[derive(Debug, Clone, PartialEq, GameEvent)] -pub enum PitEvent { - #[game_event(id = 1)] - Hello { - entity: u32, - half: f32, - boot: String, - attack_range: f32, - attack_arc_cos: f32, - }, - #[game_event(id = 3)] - Kill { - mob: u32, - tier: u8, - killer: u32, - killer_name: String, - /// The killer's kills in this room. - kills: u32, - }, - #[game_event(id = 6)] - Died { respawn_ms: u32 }, -} - -pub const ATTACK_RANGE: f32 = 2.2; -pub const ATTACK_ARC_COS: f32 = 0.5; -pub const ATTACK_DAMAGE: i32 = 10; -pub const SWING_COOLDOWN: u8 = 13; -const SWING_FRAMES: u8 = 6; -pub const PLAYER_HP: i32 = 100; -pub const MOBS: usize = 12; -const MAX_CREDITS: u8 = 4; -const VIEW_RADIUS: f32 = 40.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; -const PLAYER_RESPAWN_TICKS: u64 = 90; - -fn tier_stats(tier: u8) -> (i32, f32, i32, f32, f32) { - match tier { - 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), - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -enum MobState { - Idle = 0, - Chase = 1, - Windup = 2, - Recover = 3, - Return = 4, - Dead = 5, -} - -#[derive(Serialize, Deserialize)] -struct Mob { - id: u32, - tier: u8, - home: (f32, f32), - x: f32, - y: f32, - face: f32, - hp: i32, - state: MobState, - timer: u16, - #[serde(skip)] - target: Option, - generation: u32, - respawn_at: u64, - wander: (f32, f32), -} - -struct Player { - id: EntityId, - principal: String, - name: String, - state: MoveState, - credits: u8, - hp: i32, - face: f32, - swing_cd: u8, - swing_anim: u8, - dead_until: Option, - regen: u8, - kills: u32, -} - -/// The persisted part of a room. -#[derive(Serialize, Deserialize)] -struct Snapshot { - mobs: Vec, - kills: u32, - ticks: u64, - activations: u32, - kills_by: BTreeMap, -} - -/// One Mob Pit room. -pub struct Pit { - players: BTreeMap, - mobs: Vec, - pillars: Vec, - /// Lifetime kills (persisted). - pub kills: u32, - /// Lifetime ticks (persisted; this activation adds `ctx.tick()`). - pub ticks_before: u64, - tick: u64, - /// Kills per principal (persisted). - pub kills_by: BTreeMap, - /// Activations including this one (persisted). - pub activations: u32, - /// Host info (set by the Durable Object). - pub host: HostInfo, -} - -fn mob_layout() -> Vec<(u8, (f32, f32))> { - (0..MOBS) - .map(|i| { - let ring = if i % 2 == 0 { 11.5 } else { 16.0 }; - let a = (i as f32 + 0.5) / MOBS as f32 * std::f32::consts::TAU; - let tier = match i { - 0 => 3, - 3 | 6 | 9 | 11 => 2, - _ => 1, - }; - (tier, (ring * a.cos(), ring * a.sin())) - }) - .collect() -} - -fn spawn_point(player: PlayerId) -> (f32, f32) { - const SPOTS: [(f32, f32); 6] = [ - (-4.5, -4.5), - (4.5, -4.5), - (4.5, 4.5), - (-4.5, 4.5), - (0.0, -5.5), - (0.0, 5.5), - ]; - 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 { - let (ox, oy) = (*x - px, *y - py); - let d2 = ox * ox + oy * oy; - let min = radius + pr; - if d2 < min * min { - let d = d2.sqrt().max(1e-4); - *x = px + ox / d * min; - *y = py + oy / d * min; - } - } - let lim = movement::HALF - radius; - *x = x.clamp(-lim, lim); - *y = y.clamp(-lim, lim); -} - -fn step_toward(x: &mut f32, y: &mut f32, tx: f32, ty: f32, stride: f32) -> f32 { - let (dx, dy) = (tx - *x, ty - *y); - let d = (dx * dx + dy * dy).sqrt(); - if d > 1e-4 { - let s = stride.min(d); - *x += dx / d * s; - *y += dy / d * s; - } - dy.atan2(dx) -} - -fn dist(a: (f32, f32), b: (f32, f32)) -> f32 { - ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() -} - -impl Pit { - /// Lifetime ticks. - pub fn ticks(&self) -> u64 { - self.ticks_before + self.tick - } - - /// Kills of one principal in this room. - pub fn kills_of(&self, principal: &str) -> u32 { - self.kills_by.get(principal).copied().unwrap_or(0) - } - - fn step_players(&mut self, ctx: &mut Ctx<'_, Self>) -> Vec<(PlayerId, f32, f32, f32)> { - let tick = ctx.tick(); - let mut swings = Vec::new(); - 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.swing_anim = p.swing_anim.saturating_sub(1); - if let Some(until) = p.dead_until { - let _ = ctx.take_inputs(*id, ConsumePolicy::Drain); - if tick >= until { - let (x, y) = spawn_point(*id); - p.state = MoveState { - x, - y, - ..MoveState::default() - }; - p.hp = PLAYER_HP; - p.dead_until = None; - } - continue; - } - while p.credits > 0 { - let Some(input) = ctx.take_inputs(*id, ConsumePolicy::Next).pop() else { - break; - }; - let input = input.input; - p.state = pit_step(&p.state, &input, &(), 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 { - p.face = sy.atan2(sx); - } - if input.attack() && p.swing_cd == 0 { - p.swing_cd = SWING_COOLDOWN; - p.swing_anim = SWING_FRAMES; - swings.push((*id, p.state.x, p.state.y, p.face)); - } - } - p.regen = p.regen.wrapping_add(1); - if p.regen % 15 == 0 && p.hp < PLAYER_HP { - p.hp += 1; - } - ctx.set_view(*id, View::new(p.state.x, p.state.y, VIEW_RADIUS)); - } - swings - } - - fn resolve_swings(&mut self, ctx: &mut Ctx<'_, Self>, swings: Vec<(PlayerId, f32, f32, f32)>) { - for (player, x, y, face) in swings { - let (fx, fy) = (face.cos(), face.sin()); - for i in 0..self.mobs.len() { - let m = &mut self.mobs[i]; - if m.state == MobState::Dead { - continue; - } - let (_, _, _, radius, _) = tier_stats(m.tier); - let (dx, dy) = (m.x - x, m.y - y); - let d = (dx * dx + dy * dy).sqrt(); - if d > ATTACK_RANGE + radius { - continue; - } - let cos = if d < 1e-3 { - 1.0 - } else { - (dx * fx + dy * fy) / d - }; - 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; - let (mob, tier) = (m.id, m.tier); - self.kills += 1; - if let Some(p) = self.players.get_mut(&player) { - let kills = self.kills_by.entry(p.principal.clone()).or_default(); - *kills += 1; - let kills = *kills; - p.kills = kills; - ctx.broadcast(&PitEvent::Kill { - mob, - tier, - killer: p.id.get(), - killer_name: p.name.clone(), - kills, - }); - } - } - } - } - } - - fn step_mobs(&mut self, ctx: &mut Ctx<'_, Self>) { - let dt = ctx.dt_secs(); - let tick = ctx.tick(); - let living: Vec<(PlayerId, f32, f32)> = self - .players - .iter() - .filter(|(_, p)| p.dead_until.is_none()) - .map(|(id, p)| (*id, p.state.x, p.state.y)) - .collect(); - let mut hits: Vec<(PlayerId, i32)> = Vec::new(); - for i in 0..self.mobs.len() { - let (max_hp, speed, damage, radius, tele) = tier_stats(self.mobs[i].tier); - 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]; - match m.state { - MobState::Dead => { - if tick >= m.respawn_at { - m.generation += 1; - m.hp = max_hp; - (m.x, m.y) = m.home; - m.state = MobState::Idle; - } - continue; - } - MobState::Idle => { - let nearest = living - .iter() - .map(|(id, x, y)| (*id, dist((m.x, m.y), (*x, *y)))) - .filter(|(_, d)| *d < AGGRO) - .min_by(|a, b| a.1.total_cmp(&b.1)); - if let Some((id, _)) = nearest { - m.state = MobState::Chase; - m.target = Some(id); - } else if dist((m.x, m.y), m.wander) < 0.3 { - let a = ctx.rng().next_f32() * std::f32::consts::TAU; - let r = ctx.rng().next_f32() * 2.5; - let m = &mut self.mobs[i]; - m.wander = (m.home.0 + r * a.cos(), m.home.1 + r * a.sin()); - } else { - let (wx, wy) = m.wander; - m.face = step_toward(&mut m.x, &mut m.y, wx, wy, 1.0 * dt); - } - } - MobState::Chase => match target_pos(m.target) { - Some((tx, ty)) if dist(m.home, (m.x, m.y)) < LEASH => { - let reach = radius + PLAYER_RADIUS + 0.6; - if dist((m.x, m.y), (tx, ty)) <= reach { - m.state = MobState::Windup; - 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.state = MobState::Return; - m.target = None; - } - }, - MobState::Windup => { - 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 { - hits.push((*id, damage)); - } - } - m.state = MobState::Recover; - m.timer = RECOVER_TICKS; - } - } - MobState::Recover => { - m.timer = m.timer.saturating_sub(1); - if m.timer == 0 { - m.state = MobState::Chase; - } - } - 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 dist((m.x, m.y), m.home) < 0.3 { - m.state = MobState::Idle; - m.wander = m.home; - } - } - } - let m = &mut self.mobs[i]; - push_out_of_pillars(&mut m.x, &mut m.y, radius); - } - for i in 0..self.mobs.len() { - for j in (i + 1)..self.mobs.len() { - let (a, b) = (&self.mobs[i], &self.mobs[j]); - if a.state == MobState::Dead || b.state == MobState::Dead { - continue; - } - let min = tier_stats(a.tier).3 + tier_stats(b.tier).3; - let (dx, dy) = (b.x - a.x, b.y - a.y); - let d = (dx * dx + dy * dy).sqrt(); - if d < min && d > 1e-4 { - let push = (min - d) * 0.5; - let (nx, ny) = (dx / d * push, dy / d * push); - self.mobs[i].x -= nx; - self.mobs[i].y -= ny; - self.mobs[j].x += nx; - self.mobs[j].y += ny; - } - } - } - for (id, damage) in hits { - let Some(p) = self.players.get_mut(&id) else { - continue; - }; - if p.dead_until.is_some() { - continue; - } - p.hp -= damage; - if p.hp <= 0 { - p.hp = 0; - p.state.vx = 0.0; - p.state.vy = 0.0; - p.dead_until = Some(tick + PLAYER_RESPAWN_TICKS); - for m in &mut self.mobs { - if m.target == Some(id) { - m.target = None; - } - } - ctx.send( - id, - &PitEvent::Died { - respawn_ms: (PLAYER_RESPAWN_TICKS * 1000 / 30) as u32, - }, - ); - } - } - } -} - -impl Zone for Pit { - type Config = (); - type Input = PitInput; - type Timer = (); - type Message = distributed_game::NoMessages; - - fn schema() -> EntitySchema { - schema![PlayerView, MobView, PillarView, PitView, HostView] - } - - fn entities() -> Entities { - Entities::new() - .each(|z: &Self| &z.players) - .each(|z: &Self| &z.mobs) - .each(|z: &Self| &z.pillars) - .one(|z: &Self| z) - .one(|z: &Self| &z.host) - } - - fn on_create(_ctx: &mut Ctx<'_, Self>, _config: ()) -> Self { - let mobs = mob_layout() - .into_iter() - .enumerate() - .map(|(i, (tier, home))| Mob { - id: i as u32 + 1, - tier, - home, - x: home.0, - y: home.1, - face: 0.0, - hp: tier_stats(tier).0, - state: MobState::Idle, - timer: 0, - target: None, - generation: 0, - respawn_at: 0, - wander: home, - }) - .collect(); - Self { - players: BTreeMap::new(), - mobs, - pillars: Pillar::all(), - kills: 0, - ticks_before: 0, - tick: 0, - kills_by: BTreeMap::new(), - activations: 1, - host: HostInfo::default(), - } - } - - fn on_join(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId) { - let (principal, name) = ctx - .player(player) - .map(|p| (p.principal.clone(), p.name.clone())) - .unwrap_or_default(); - let name = name.unwrap_or_else(|| principal.clone()); - let kills = self.kills_of(&principal); - let (x, y) = spawn_point(player); - let entity = EntityId(1000 + player.0); - self.players.insert( - player, - Player { - id: entity, - principal, - name, - state: MoveState { - x, - y, - ..MoveState::default() - }, - credits: MAX_CREDITS, - hp: PLAYER_HP, - face: 0.0, - swing_cd: 0, - swing_anim: 0, - dead_until: None, - regen: 0, - kills, - }, - ); - ctx.set_view(player, View::new(x, y, VIEW_RADIUS)); - ctx.send( - player, - &PitEvent::Hello { - entity: entity.get(), - half: movement::HALF, - boot: self.host.activation.clone(), - attack_range: ATTACK_RANGE, - attack_arc_cos: ATTACK_ARC_COS, - }, - ); - } - - fn on_leave(&mut self, _ctx: &mut Ctx<'_, Self>, player: PlayerId, _reason: LeaveReason) { - self.players.remove(&player); - for m in &mut self.mobs { - if m.target == Some(player) { - m.target = None; - } - } - } - - fn on_observe(&mut self, ctx: &mut Ctx<'_, Self>, observer: ObserverId) { - ctx.set_view(observer, View::new(0.0, 0.0, VIEW_RADIUS)); - } - - fn decode_input(&self, _player: PlayerId, payload: &[u8]) -> Option { - PitInput::from_payload(payload) - } - - fn on_tick(&mut self, ctx: &mut Ctx<'_, Self>) { - self.tick = ctx.tick(); - let swings = self.step_players(ctx); - self.resolve_swings(ctx, swings); - self.step_mobs(ctx); - } - - fn hibernate(&self) -> Option> { - #[derive(Serialize)] - struct SnapshotRef<'a> { - mobs: &'a [Mob], - kills: u32, - ticks: u64, - activations: u32, - kills_by: &'a BTreeMap, - } - serde_json::to_vec(&SnapshotRef { - mobs: &self.mobs, - kills: self.kills, - ticks: self.ticks(), - activations: self.activations, - kills_by: &self.kills_by, - }) - .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; - }; - // Respawn timers are tick numbers of the old activation. - self.mobs = snapshot - .mobs - .into_iter() - .map(|mut m| { - m.respawn_at = m - .respawn_at - .saturating_sub(snapshot.ticks.min(m.respawn_at)); - if m.state != MobState::Dead { - m.state = MobState::Return; - } - m - }) - .collect(); - self.kills = snapshot.kills; - self.ticks_before = snapshot.ticks; - self.activations = snapshot.activations + 1; - self.kills_by = snapshot.kills_by; - } -} - -impl Entity for Player { - fn entity_id(&self) -> EntityId { - self.id - } -} - -impl Project for Player { - fn view(&self) -> Option { - Some(PlayerView::project( - self, - PlayerViewRest { - hp: self.hp.clamp(0, 255) as u8, - swing: self.swing_anim, - level: self.kills.min(u32::from(u16::MAX)) as u16, - dead: self.dead_until.is_some(), - }, - )) - } -} - -impl Entity for Mob { - fn entity_id(&self) -> EntityId { - EntityId(self.id) - } -} - -impl Project for Mob { - fn view(&self) -> Option { - if self.state == MobState::Dead { - return None; - } - let max_hp = tier_stats(self.tier).0; - let tele = if self.state == MobState::Windup { - 255 - (u32::from(self.timer) * 255 / u32::from(WINDUP_TICKS)) - } else { - 0 - }; - Some(MobView::project( - self, - MobViewRest { - hp: (self.hp.max(0) * 100 / max_hp) as u8, - state: self.state as u8, - tele: tele as u8, - }, - )) - } -} - -impl Entity for Pit { - fn entity_id(&self) -> EntityId { - PIT_ENTITY - } -} - -impl Project for Pit { - fn view(&self) -> Option { - Some(PitView::project( - self, - PitViewRest { - kills: self.kills, - ticks: self.ticks().min(u64::from(u32::MAX)) as u32, - }, - )) - } -} diff --git a/examples/game/zone-do-spike/tools/baseline.mjs b/examples/game/zone-do-spike/tools/baseline.mjs deleted file mode 100644 index 5809396a..00000000 --- a/examples/game/zone-do-spike/tools/baseline.mjs +++ /dev/null @@ -1,5 +0,0 @@ -// Noise floor: setTimeout 30 Hz loop lateness in a plain Node process. -const hz = Number(process.argv[2] ?? 30), secs = Number(process.argv[3] ?? 15), step = 1000 / hz; -const start = performance.now(); const late = []; let k = 1; -function tick() { const now = performance.now(); late.push(now - (start + (k - 1) * step)); k = Math.floor((now - start) / step) + 2; if (now - start < secs * 1000) setTimeout(tick, Math.max(0, start + (k - 1) * step - performance.now())); else { late.shift(); const s = late.sort((a, b) => a - b); console.log(JSON.stringify({ baselineNodeTimer: { hz, p50: +s[Math.floor(s.length / 2)].toFixed(2), p99: +s[Math.floor(s.length * 0.99)].toFixed(2), max: +s.at(-1).toFixed(2) } })); } } -setTimeout(tick, step); diff --git a/examples/game/zone-do-spike/tools/bench.ts b/examples/game/zone-do-spike/tools/bench.ts deleted file mode 100644 index ba5e6162..00000000 --- a/examples/game/zone-do-spike/tools/bench.ts +++ /dev/null @@ -1,219 +0,0 @@ -// Spike gr-p42 measurements with raw protocol bots (no rendering). -// -// node bench.mjs join join phase timings -// node bench.mjs rtt [n] Ping/Pong RTT through -// node bench.mjs load [hz] [pids] -// one bot per room (moves + attacks at 30 Hz); all rooms first touched -// through so celld places them on that node. Reports the -// rooms' own loop timing (/rooms//stats), Delta inter-arrival at -// the bots and the node processes' CPU (ps) while loaded. -import { execFileSync } from 'node:child_process'; -import { decodeServerMessage, encodeClientMessage, encodePayload, type PayloadLayout } from '../../../../js/src/game/index.ts'; - -const INPUT: PayloadLayout = [['mx', { type: 'i8' }], ['my', { type: 'i8' }], ['buttons', { type: 'u8' }]]; -const TAG_DELTA = 0x81; // GameMessageTag.DELTA -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); -const q = (a: number[], p: number) => { if (!a.length) return NaN; const s = [...a].sort((x, y) => x - y); return +s[Math.round(p * (s.length - 1))].toFixed(2); }; -const summary = (a: number[]) => ({ n: a.length, p50: q(a, 0.5), p99: q(a, 0.99), max: q(a, 1) }); - -type Bot = { ws: WebSocket; deltas: number[]; pongs: Map void>; seq: number; timings: Record }; - -async function bot(base: string, room: string, name: string): Promise { - const t0 = performance.now(); - const r = await fetch(`${base}/ticket?room=${encodeURIComponent(room)}&name=${name}`); - const { ticket } = (await r.json()) as { ticket: string }; - const tTicket = performance.now(); - const ws = new WebSocket(`${base.replace(/^http/, 'ws')}/rooms/${encodeURIComponent(room)}`); - ws.binaryType = 'arraybuffer'; - const b: Bot = { ws, deltas: [], pongs: new Map(), seq: 0, timings: {} }; - await new Promise((ok, fail) => { ws.onopen = () => ok(); ws.onerror = () => fail(new Error(`ws error ${room}`)); }); - const tOpen = performance.now(); - ws.send(encodeClientMessage({ type: 'Hello', protocolVersion: 1, contentVersion: '', capabilities: 0, ticket, reconnectToken: '' })); - await new Promise((ok, fail) => { - let welcomed = false; - ws.onclose = (e) => { if (!welcomed) fail(new Error(`closed ${e.code} ${e.reason}`)); }; - ws.onmessage = (e) => { - const bytes = new Uint8Array(e.data as ArrayBuffer); - const now = performance.now(); - if (bytes[0] === TAG_DELTA) { b.deltas.push(now); if (!b.timings.firstDelta) b.timings.firstDelta = now - t0; return; } - const m = decodeServerMessage(bytes); - if (m.type === 'Welcome') { welcomed = true; b.timings.welcome = now - t0; ok(); } - if (m.type === 'Pong') b.pongs.get(m.clientTime)?.(now); - if (m.type === 'Error') fail(new Error(`error ${m.code} ${m.message}`)); - }; - }); - b.timings.ticket = tTicket - t0; - b.timings.open = tOpen - tTicket; - b.timings.hello = b.timings.welcome - (tOpen - t0); - return b; -} - -function ping(b: Bot): Promise { - const t = performance.now(); - return new Promise((ok) => { b.pongs.set(t, (now) => { b.pongs.delete(t); ok(now - t); }); b.ws.send(encodeClientMessage({ type: 'Ping', clientTime: t })); }); -} - -function drive(b: Bot, phase: number) { - let k = phase; - return setInterval(() => { - k++; - const a = k / 40; - b.seq++; - b.ws.send(encodeClientMessage({ type: 'Input', seq: b.seq, clientTick: b.seq, payload: encodePayload(INPUT, { mx: Math.round(Math.cos(a) * 100), my: Math.round(Math.sin(a) * 100), buttons: 2 }) })); - }, 1000 / 30); -} - -function cpu(pids: string[]) { - if (!pids.length) return []; - const out = execFileSync('ps', ['-o', 'pid=,%cpu=,rss=', '-p', pids.join(',')]).toString().trim().split('\n'); - return out.map((l) => { const [pid, c, rss] = l.trim().split(/\s+/); return { pid, cpu: +c, rssMb: Math.round(+rss / 1024) }; }); -} - -const [mode, base, room, ...rest] = process.argv.slice(2); -if (mode === 'join') { - // Warm the Node HTTP/WebSocket stack (its first use costs 50-500 ms). - const w = await bot(base, 'warmup', 'w'); - w.ws.close(); - for (const r of room.split(',')) { - const b = await bot(base, r, 'joiner'); - await sleep(200); - console.log(JSON.stringify({ room: r, via: base, ...Object.fromEntries(Object.entries(b.timings).map(([k, v]) => [k, +v.toFixed(1)])) })); - b.ws.close(); - } -} else if (mode === 'wake') { - // wake : create n rooms - // through the owner (fresh activation), leave, wait for eviction, then - // rejoin each (even through the owner, odd through ). - const [n, waitS, other] = rest; - const w = await bot(base, 'warmup', 'w'); w.ws.close(); - const w2 = await bot(other, 'warmup', 'w'); w2.ws.close(); - const tag = Date.now().toString(36); - const rooms = Array.from({ length: Number(n) }, (_, i) => `${room}-${tag}-${i}`); - const fresh: number[] = []; - const before: Record = {}; const after: Record = {}; const moved: string[] = []; - const ownerOf = async (r: string) => ((await (await fetch(`${base}/rooms/${encodeURIComponent(r)}/stats`)).json()) as any).node as string; - const first: string[] = []; - for (const r of rooms) { const b = await bot(base, r, 'x'); fresh.push(b.timings.welcome); await sleep(300); const o = await ownerOf(r); first.push(o); before[o] = (before[o] ?? 0) + 1; b.ws.close(); } - await sleep(Number(waitS) * 1000); - const viaOwner: number[] = []; const viaOther: number[] = []; const restored: boolean[] = []; - for (const [i, r] of rooms.entries()) { - const b = await bot(i % 2 ? other : base, r, 'x'); - (i % 2 ? viaOther : viaOwner).push(b.timings.welcome); - b.ws.close(); - const st = await (await fetch(`${base}/rooms/${encodeURIComponent(r)}/stats`)).json() as any; - restored.push(st.restored && st.activations === 2); - after[st.node] = (after[st.node] ?? 0) + 1; - if (st.node !== first[i]) moved.push(`${r}:${first[i]}->${st.node}`); - } - console.log(JSON.stringify({ fresh: summary(fresh), wakeViaOwner: summary(viaOwner), wakeViaOther: summary(viaOther), restoredAll: restored.every(Boolean), restored: restored.filter(Boolean).length, ownersBefore: before, ownersAfter: after, moved: moved.length, movedRooms: moved.slice(0, 5) })); -} else if (mode === 'rtt') { - const n = Number(rest[0] ?? 200); - const b = await bot(base, room, 'pinger'); - const rtts: number[] = []; - const t0 = performance.now(); const spikes: string[] = []; - for (let i = 0; i < n; i++) { const r = await ping(b); rtts.push(r); if (r > 15) spikes.push(`${((performance.now() - t0) / 1000).toFixed(2)}s:${r.toFixed(0)}`); await sleep(10); } - if (process.env.SPIKES) console.log('spikes', spikes.join(' ')); - const st = await (await fetch(`${base}/rooms/${encodeURIComponent(room)}/stats`)).json() as any; - console.log(JSON.stringify({ room, via: base, owner: st.node, rtt: summary(rtts) })); - b.ws.close(); -} else if (mode === 'crowd') { - // crowd [hz] [pids] [bases]: bots - // in ONE room, joins spread over [bases] (comma list, default ), so - // every bot not on the owner node is forwarded. Reports the room's loop - // timing, per-bot Delta size/interval/bandwidth, ping and node CPU. - const [count, secs, hz = '30', pidList = '', baseList = ''] = rest; - const pids = pidList ? pidList.split(',') : []; - const bases = baseList ? baseList.split(',') : [base]; - const r = `${room}@${hz}`; - const bots: Bot[] = []; - const bytes: number[][] = []; - const t0 = performance.now(); - for (let i = 0; i < Number(count); i += 10) { - const batch = await Promise.all(Array.from({ length: Math.min(10, Number(count) - i) }, (_, j) => bot(bases[(i + j) % bases.length], r, `p${i + j}`))); - for (const b of batch) { - const sizes: number[] = []; - bytes.push(sizes); - const prev = b.ws.onmessage!; - b.ws.onmessage = (e) => { const d = e.data as ArrayBuffer; if (new Uint8Array(d)[0] === TAG_DELTA) sizes.push(d.byteLength); (prev as any).call(b.ws, e); }; - bots.push(b); - } - } - const joinS = (performance.now() - t0) / 1000; - const welcome = bots.map((b) => b.timings.welcome); - const timers = bots.map((b, i) => drive(b, i * 7)); - await sleep(3000); - await fetch(`${base}/rooms/${encodeURIComponent(r)}/stats?reset=1`).catch(() => undefined); - for (const b of bots) b.deltas.length = 0; - for (const s of bytes) s.length = 0; - const samples: ReturnType[] = []; - const end = Date.now() + Number(secs) * 1000; - while (Date.now() < end) { await sleep(1000); samples.push(cpu(pids)); } - const st = (await (await fetch(`${base}/rooms/${encodeURIComponent(r)}/stats`)).json()) as any; - const rtts: number[] = []; - for (const b of bots.filter((_, i) => i % 5 === 0)) rtts.push(await ping(b)); - timers.forEach(clearInterval); - const arrivals = bots.flatMap((b) => b.deltas.slice(1).map((t, i) => t - b.deltas[i])); - const sizes = bytes.flat(); - const perBotKBps = bytes.map((s) => s.reduce((a, x) => a + x, 0) / 1024 / Number(secs)); - const cpuByPid: Record = {}; - for (const s of samples) for (const c of s) (cpuByPid[c.pid] ??= []).push(c.cpu); - console.log(JSON.stringify({ - room: r, players: bots.length, connected: bots.filter((b) => b.ws.readyState === 1).length, hz: Number(hz), secs: Number(secs), - joinS: +joinS.toFixed(1), welcome: summary(welcome), owner: st.node, - lateP50: st.late_ms?.p50, lateP99: st.late_ms?.p99, intervalP99: st.interval_ms?.p99, droppedTicks: st.dropped_ticks, - iterations: st.iterations, expectedIterations: Math.round(Number(hz) * Number(secs)), - deltaBytes: summary(sizes), deltaArrival: summary(arrivals), - perBotKBps: summary(perBotKBps), roomOutKBps: +perBotKBps.reduce((a, x) => a + x, 0).toFixed(1), - pingUnderLoad: summary(rtts), - cpu: Object.fromEntries(Object.entries(cpuByPid).map(([p, a]) => [p, { avg: +(a.reduce((x, y) => x + y, 0) / a.length).toFixed(1), max: Math.max(...a) }])) - })); - for (const b of bots) b.ws.close(); - await sleep(500); -} else if (mode === 'load') { - const [count, secs, hz = '30', pidList = ''] = rest; - const pids = pidList ? pidList.split(',') : []; - const rooms = Array.from({ length: Number(count) }, (_, i) => `${room}-${i}@${hz}`); - const bots: Bot[] = []; - const t0 = performance.now(); - for (let i = 0; i < rooms.length; i += 20) { - bots.push(...(await Promise.all(rooms.slice(i, i + 20).map((r, j) => bot(base, r, `b${i + j}`))))); - } - const joinS = (performance.now() - t0) / 1000; - const timers = bots.map((b, i) => drive(b, i * 7)); - await sleep(3000); - await Promise.all(rooms.map((r) => fetch(`${base}/rooms/${encodeURIComponent(r)}/stats?reset=1`).catch(() => undefined))); - for (const b of bots) b.deltas.length = 0; - const samples: ReturnType[] = []; - const end = Date.now() + Number(secs) * 1000; - while (Date.now() < end) { await sleep(1000); samples.push(cpu(pids)); } - const fetched = await Promise.all(rooms.map(async (r) => { try { return await (await fetch(`${base}/rooms/${encodeURIComponent(r)}/stats`)).json() as any; } catch { return undefined; } })); - const stats = fetched.filter(Boolean); - if (stats.length < rooms.length) console.log(`stats unavailable for ${rooms.length - stats.length} rooms`); - const rtts: number[] = []; - for (const b of bots.slice(0, 10)) rtts.push(await ping(b)); - timers.forEach(clearInterval); - const late99 = stats.map((s) => s.late_ms.p99); - const late50 = stats.map((s) => s.late_ms.p50); - const interval = stats.map((s) => s.interval_ms.p99); - const dropped = stats.reduce((a, s) => a + s.dropped_ticks, 0); - const catchUp = stats.reduce((a, s) => a + s.catch_up_iterations, 0); - const iterations = stats.reduce((a, s) => a + s.iterations, 0); - const arrivals = bots.flatMap((b) => b.deltas.slice(1).map((t, i) => t - b.deltas[i])); - const nodes: Record = {}; - for (const s of stats) nodes[s.node] = (nodes[s.node] ?? 0) + 1; - const isolates = new Set(stats.map((s) => s.isolate)).size; - const cpuByPid: Record = {}; - for (const s of samples) for (const c of s) (cpuByPid[c.pid] ??= []).push(c.cpu); - console.log(JSON.stringify({ - rooms: rooms.length, hz: Number(hz), secs: Number(secs), joinS: +joinS.toFixed(1), nodes, isolates, - lateP50_median: q(late50, 0.5), lateP99_median: q(late99, 0.5), lateP99_worstRoom: q(late99, 1), - intervalP99_median: q(interval, 0.5), droppedTicks: dropped, catchUpIterations: catchUp, iterations, - expectedIterations: Math.round(rooms.length * Number(hz) * Number(secs)), - deltaArrival: summary(arrivals), pingUnderLoad: summary(rtts), - cpu: Object.fromEntries(Object.entries(cpuByPid).map(([p, a]) => [p, { avg: +(a.reduce((x, y) => x + y, 0) / a.length).toFixed(1), max: Math.max(...a) }])), - rss: samples.at(-1) - })); - for (const b of bots) b.ws.close(); - await sleep(500); -} -process.exit(0); diff --git a/examples/game/zone-do-spike/tools/drain.ts b/examples/game/zone-do-spike/tools/drain.ts deleted file mode 100644 index f5be9ae9..00000000 --- a/examples/game/zone-do-spike/tools/drain.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Graceful node stop: create rooms through , leave, wait for -// eviction, print READY (operator stops that node with SIGTERM), then -// rejoin each through and report where it runs now. -import { connectRoom } from '../page/room-client.ts'; -const [ownerBase, entry, n, waitS] = process.argv.slice(2); -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); -const tag = Date.now().toString(36); -const rooms = Array.from({ length: Number(n) }, (_, i) => `dr-${tag}-${i}`); -const node = async (base: string, r: string) => ((await (await fetch(`${base}/rooms/${encodeURIComponent(r)}/stats`)).json()) as any); -const before: Record = {}; -for (const r of rooms) { - const rc = connectRoom(ownerBase, r, 'd'); await rc.welcome; await sleep(400); rc.close(); - const st = await node(ownerBase, r); before[st.node] = (before[st.node] ?? 0) + 1; -} -console.log('placed', JSON.stringify(before)); -await sleep(Number(waitS) * 1000); -console.log('READY'); -for (let i = 0; i < 200 && !(await fetch(`${ownerBase}/ticket`).then(() => false, () => true)); i++) await sleep(100); -const t0 = performance.now(); -const after: Record = {}; let restored = 0; const waits: number[] = []; -for (const r of rooms) { - const t = performance.now(); - for (;;) { try { const rc = connectRoom(entry, r, 'd'); await rc.welcome; rc.close(); break; } catch { await sleep(200); } } - waits.push(performance.now() - t); - const st = await node(entry, r); after[st.node] = (after[st.node] ?? 0) + 1; if (st.restored) restored++; -} -console.log('after stop', JSON.stringify({ owners: after, restored, rooms: rooms.length, firstRejoinMs: Math.round(waits[0]), allRejoinedS: +((performance.now() - t0) / 1000).toFixed(1) })); -process.exit(0); diff --git a/examples/game/zone-do-spike/tools/failover.ts b/examples/game/zone-do-spike/tools/failover.ts deleted file mode 100644 index 7809d5b4..00000000 --- a/examples/game/zone-do-spike/tools/failover.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Node failure: a room placed on one node (first touch through ) -// with a player connected through another node (); the -// operator SIGKILLs the owner when this prints READY. The player then -// rejoins through until a room answers again. -// usage: node failover.mjs -import { connectRoom, ATTACK } from '../page/room-client.ts'; -const [ownerBase, entry, room] = process.argv.slice(2); -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); -const log = (...a: unknown[]) => console.log(new Date().toISOString().slice(11, 23), ...a); -// First touch through the owner places the room there. -await (await fetch(`${ownerBase}/rooms/${encodeURIComponent(room)}/stats`)).json(); -const rc = connectRoom(entry, room, 'survivor'); -await rc.welcome; -for (let i = 0; i < 100 && !(rc.host()?.node && rc.host()!.node !== '?'); i++) await sleep(50); -log(`joined ${room} via ${entry}; runs on ${rc.host()?.node}`); -let closedAt = 0; -rc.client.on('zoneClosed', (_z, info) => { closedAt = performance.now(); log('connection closed', JSON.stringify(info)); }); -const fight = setInterval(() => { - const me = rc.me()?.fields as any; const mobs = rc.entities().filter((e) => e.kindName === 'mob').map((e) => e.fields as any); - if (!me || !mobs.length) return; - const t = mobs.sort((a, b) => Math.hypot(a.x - me.x, a.y - me.y) - Math.hypot(b.x - me.x, b.y - me.y))[0]; - const [dx, dy] = [t.x - me.x, t.y - me.y]; const d = Math.hypot(dx, dy) || 1; const s = d < 1.8 ? 30 : 127; - rc.input(Math.round(dx / d * s), Math.round(dy / d * s), ATTACK); -}, 33); -while (rc.kills.length < 3) await sleep(100); -await sleep(6000); // > the room's 5 s persist interval -const before = rc.kills.at(-1)!.kills; -log(`kills before failure: ${before} (persisted at most 5 s ago); READY`); -const t0 = performance.now(); -while (!closedAt && performance.now() - t0 < 60_000) await sleep(20); -clearInterval(fight); -const killDetected = closedAt; -let attempt = 0; -for (;;) { - attempt++; - try { - const r2 = connectRoom(entry, room, 'survivor'); - await r2.welcome; - for (let i = 0; i < 100 && !(r2.host()?.node && r2.host()!.node !== '?' && r2.me()); i++) await sleep(50); - const h = r2.host()!; const me = r2.me()?.fields as any; - log(`rejoined after ${((performance.now() - killDetected) / 1000).toFixed(2)} s (${attempt} attempts): node=${h.node} activation #${h.activations} restored=${h.restored}; my kills ${me?.level} (had ${before})`); - r2.close(); break; - } catch (e) { - if (attempt % 10 === 1) log(`attempt ${attempt}: ${String(e).slice(0, 120)}`); - await sleep(250); - } -} -process.exit(0); diff --git a/examples/game/zone-do-spike/tools/headless.ts b/examples/game/zone-do-spike/tools/headless.ts deleted file mode 100644 index 545a37e1..00000000 --- a/examples/game/zone-do-spike/tools/headless.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Headless check of one room on celld: join, move, attack until a kill, -// leave, wait for eviction, rejoin (possibly via another node) and check -// the room restored its state in a new activation. -// usage: node headless.mjs [rejoin-base-url] [evict-wait-s] -import { connectRoom, ATTACK, type RoomClient } from '../page/room-client.ts'; - -const [base, room, rejoinBase = base, waitS = '25'] = process.argv.slice(2); -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); -const until = async (f: () => T | undefined | false, ms: number, what: string): Promise => { - const end = Date.now() + ms; - for (;;) { const v = f(); if (v) return v; if (Date.now() > end) throw new Error(`timeout: ${what}`); await sleep(20); } -}; -const log = (...a: unknown[]) => console.log(new Date().toISOString().slice(11, 23), ...a); - -async function join(url: string, name: string) { - const t0 = performance.now(); - const rc = connectRoom(url, room, name); - await rc.welcome; - const welcomeMs = performance.now() - t0; - await until(() => rc.host()?.node && rc.host()!.node !== '?' && rc.me(), 5000, 'host node + own player'); - return { rc, welcomeMs }; -} - -async function fight(rc: RoomClient, kills: number) { - const start = rc.kills.length; - const end = Date.now() + 60_000; - let moved = 0; - const at0 = rc.me()!.fields as any; - while (rc.kills.length - start < kills) { - if (Date.now() > end) throw new Error('no kill within 60 s'); - const me = rc.me()?.fields as any; - const mobs = rc.entities().filter((e) => e.kindName === 'mob').map((e) => e.fields as any); - if (me && mobs.length) { - const t = mobs.sort((a, b) => Math.hypot(a.x - me.x, a.y - me.y) - Math.hypot(b.x - me.x, b.y - me.y))[0]; - const [dx, dy] = [t.x - me.x, t.y - me.y]; const d = Math.hypot(dx, dy) || 1; - const near = d < 1.8; - rc.input(near ? Math.round(dx / d * 30) : Math.round(dx / d * 127), near ? Math.round(dy / d * 30) : Math.round(dy / d * 127), ATTACK); - moved = Math.max(moved, Math.hypot(me.x - at0.x, me.y - at0.y)); - } - await sleep(33); - } - return { moved }; -} - -const { rc, welcomeMs } = await join(base, 'bot-ana'); -const h1 = { ...rc.host()! }; -log(`joined ${room} via ${base}: node=${h1.node} isolate=${h1.isolate} activation=${h1.activation} #${h1.activations} restored=${h1.restored} welcome=${welcomeMs.toFixed(1)}ms`); -const kills0 = (rc.entities().find((e) => e.kindName === 'pit')?.fields as any)?.kills; -const { moved } = await fight(rc, 1); -await sleep(500); -const k = rc.kills.at(-1)!; -log(`moved ${moved.toFixed(1)} m, killed mob ${k.mob}; my kills in room=${k.kills}; room kills ${kills0} -> ${(rc.entities().find((e) => e.kindName === 'pit')?.fields as any)?.kills}`); -rc.client.close(); -log(`left; waiting ${waitS}s for the room to persist, stop and be evicted`); -await sleep(Number(waitS) * 1000); -const r2 = await join(rejoinBase, 'bot-ana'); -const h2 = { ...r2.rc.host()! }; -const me2 = r2.rc.me()!.fields as any; -const pit2 = r2.rc.entities().find((e) => e.kindName === 'pit')?.fields as any; -log(`rejoined via ${rejoinBase}: node=${h2.node} isolate=${h2.isolate} activation=${h2.activation} #${h2.activations} restored=${h2.restored} wake=${(h2 as any).wake_ms}ms welcome=${r2.welcomeMs.toFixed(1)}ms`); -log(`restored state: my kills=${me2.level} (expected ${k.kills}), room kills=${pit2?.kills}, lifetime ticks=${pit2?.ticks}`); -const ok = h2.activation !== h1.activation && h2.restored && me2.level === k.kills && pit2?.kills >= k.kills; -r2.rc.client.close(); -log(ok ? 'PASS' : 'FAIL'); -process.exit(ok ? 0 : 1); diff --git a/examples/game/zone-do-spike/tools/peek.ts b/examples/game/zone-do-spike/tools/peek.ts deleted file mode 100644 index 2d4855b6..00000000 --- a/examples/game/zone-do-spike/tools/peek.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Print one room's replicated host entity and stats. -import { connectRoom } from '../page/room-client.ts'; -const [base, room] = process.argv.slice(2); -const t0 = performance.now(); -const rc = connectRoom(base, room, 'peek'); -await rc.welcome; -const tw = performance.now() - t0; -for (let i = 0; i < 100 && !(rc.host() as any)?.node; i++) await new Promise((r) => setTimeout(r, 20)); -await new Promise((r) => setTimeout(r, 1500)); -console.log(JSON.stringify({ welcomeMs: +tw.toFixed(1), host: rc.host(), rtt: rc.rtt() })); -rc.client.close(); process.exit(0); diff --git a/examples/game/zone-do-spike/tools/s3-compose.yaml b/examples/game/zone-do-spike/tools/s3-compose.yaml deleted file mode 100644 index b0b1d8e9..00000000 --- a/examples/game/zone-do-spike/tools/s3-compose.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# The spike's one S3-compatible store (RustFS) for the celld fleet bucket. -# Compose rather than `docker run`: Dory (this machine's Docker) published -# ports only for containers on a user-defined network. -services: - s3: - image: rustfs/rustfs:1.0.3 - container_name: zone-do-spike-s3 - command: ["/data"] - environment: - RUSTFS_ADDRESS: "0.0.0.0:9000" - RUSTFS_ACCESS_KEY: spikeadmin - RUSTFS_SECRET_KEY: spikesecret123 - ports: ["127.0.0.1:${S3_PORT:-19400}:9000"] diff --git a/examples/game/zone-do-spike/wrangler.jsonc b/examples/game/zone-do-spike/wrangler.jsonc deleted file mode 100644 index 70455c40..00000000 --- a/examples/game/zone-do-spike/wrangler.jsonc +++ /dev/null @@ -1,20 +0,0 @@ -// Spike gr-p42: Mob Pit rooms as celld Durable Objects. `./run.sh deploy` -// publishes this to the fleet bucket (`celld deploy`); every node runs it. -{ - "name": "zone-do-spike", - "main": "build/worker/shim.mjs", - "compatibility_date": "2026-01-01", - "durable_objects": { - "bindings": [{ "name": "PIT_ROOM", "class_name": "PitRoom" }] - }, - "migrations": [{ "tag": "v1", "new_sqlite_classes": ["PitRoom"] }], - "assets": { "directory": "./public", "binding": "ASSETS" }, - "vars": { - // Dev-only ticket key (the spike's /ticket endpoint signs with it). - "TICKET_SECRET": "spike-only-ticket-secret-not-for-production", - "TICK_HZ": "30", - // Operator listeners, only to find out which node runs a room - // (celld does not tell application code its node id). - "NODES": "node-a=127.0.0.1:18201,node-b=127.0.0.1:18202,node-c=127.0.0.1:18203" - } -} From c50d9f1bcd6ddcf1db4e273481a33f827a143915 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 20:24:39 -0500 Subject: [PATCH 08/87] test(game): skip the local fleet when its RustFS is unreachable When Docker runs the RustFS container but its port never becomes reachable, the Playwright fleet helper now stops the container and falls back to `celld dev` (or fails with MOBPIT_FLEET_REQUIRED=1, as CI sets), instead of failing after a minute. [[tasks/gr-p41-mobpit-rooms-zones]] --- examples/game/client/tests/helpers/fleet.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/game/client/tests/helpers/fleet.mjs b/examples/game/client/tests/helpers/fleet.mjs index ad07ba34..1baca9ff 100644 --- a/examples/game/client/tests/helpers/fleet.mjs +++ b/examples/game/client/tests/helpers/fleet.mjs @@ -68,11 +68,15 @@ export async function startFleet({ relayPort, nodes = 3, vars = {} } = {}) { try { const up = spawnSync('docker', ['compose', '-p', project, '-f', compose, 'up', '-d'], { encoding: 'utf8' }); if (up.status !== 0) throw new Error(`docker compose up: ${up.stderr}`); - const deadline = Date.now() + 60_000; + const deadline = Date.now() + 30_000; for (;;) { const mb = spawnSync('aws', ['--endpoint-url', aws.S3_ENDPOINT, 's3', 'mb', 's3://mobpit-fleet'], { env: aws, encoding: 'utf8' }); if (mb.status === 0 || /BucketAlready/.test(mb.stderr)) break; - if (Date.now() > deadline) throw new Error(`bucket: ${mb.stderr}`); + if (Date.now() > deadline) { + // Docker ran the container but its port is not reachable here. + down(); + return skip(`RustFS unreachable on 127.0.0.1:${s3Port}: ${mb.stderr.trim()}`); + } await new Promise((r) => setTimeout(r, 500)); } const secret = `test-only-${randomBytes(24).toString('hex')}`; From 4a3edd35f064f2e629edd2c3bdd1086323fbf736 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 22:40:07 -0500 Subject: [PATCH 09/87] feat(game): predict through handoffs with handoffFrame GameClient used to hold the controlled entity still while a handoff was in flight and drop the inputs sampled meanwhile, so a border crossing showed a hold, then a hop. With prediction.handoffFrame (the predicted fields re-expressed in the target zone's frame) the client keeps predicting in the target's frame, sends the transfer's inputs to the target once it welcomes the player, and eases from what was drawn into the target's state. Predictor.bind keeps (and replays) inputs recorded while no entity or the same entity was bound, filtered by the delta's ack, instead of dropping them; new step() and easeFrom(). [[tasks/gr-p41-mobpit-rooms-zones]] --- js/README.md | 9 ++- js/src/game/client.ts | 71 +++++++++++++++++++++-- js/src/game/prediction.ts | 54 +++++++++++++++-- js/tests/game-replica-prediction.test.mjs | 14 +++++ js/tests/game-world.test.mjs | 59 +++++++++++++++++++ 5 files changed, 195 insertions(+), 12 deletions(-) diff --git a/js/README.md b/js/README.md index 371f9d8b..3a09c189 100644 --- a/js/README.md +++ b/js/README.md @@ -787,7 +787,14 @@ every Delta's `ack_seq`; per-field `ease`, `snap` or `replace` resolves the divergence. A server Handoff connects to the target zone with the transfer ticket (following `Redirect`s) and swaps authority while the previous world keeps rendering; if the target refuses or stays silent the same Hello goes -back to the source zone, then a fresh ticket. `Kick(RELOAD)` and handshake +back to the source zone, then a fresh ticket. Meanwhile the controlled +entity is held at its last predicted state; with `prediction.handoffFrame` +(its predicted fields re-expressed in the target zone's frame, e.g. a cell +offset for zone-local positions) it is predicted on instead, the inputs of +the transfer go to the target once it welcomes the player, and the switch +to the target's state is eased, so a border crossing shows no hold or jump. +Inputs recorded before the controlled entity is known are replayed when it +binds. `Kick(RELOAD)` and handshake `VERSION_MISMATCH` emit `reloadRequired`; supply `reloadContent` (or call `connection.resumeAfterReload(version)`) to rejoin with the new content. `client.contentVersion` follows the reload, so later handoff targets and diff --git a/js/src/game/client.ts b/js/src/game/client.ts index 4b945ffa..a74e44d9 100644 --- a/js/src/game/client.ts +++ b/js/src/game/client.ts @@ -78,6 +78,17 @@ export type GamePredictionOptions = PredictorOptions & * overrides it (e.g. from a game Event). */ controlledEntity?: ControlledEntityResolver; + /** + * Continue the controlled entity's prediction through a handoff: its + * predicted fields re-expressed in the target zone's frame (e.g. a + * cell offset when positions are zone-local), or undefined to hold + * it still as before. When given, inputs sampled while the handoff + * is in flight are predicted (with the target's frame) and sent to + * the target once it welcomes the player, and the switch to the + * target's own state is eased instead of snapped: the handoff's + * latency is hidden. + */ + handoffFrame?: (fields: EntityFields, from: string, to: string) => EntityFields | undefined; }>; /** What the player sees when an input is sampled (lag compensation). */ @@ -179,6 +190,13 @@ export class GameClient { #handoffBridge: RenderedEntity | undefined; /** The source zone's copy of the handed-off entity: never drawn again. */ #handedOff: Readonly<{ zoneId: string; id: number }> | undefined; + /** + * Handoff in flight with `handoffFrame`: the controlled entity predicted + * on in the target's frame, and the inputs not yet sent to the target. + */ + #carry: + | { zoneId: string; base: RenderedEntity; fields: EntityFields; unsent: { input: TInput; dtMs: number }[] } + | undefined; #closed = false; #contentVersion: string; #predictionEnabled: boolean; @@ -297,13 +315,28 @@ export class GameClient { const authority = this.#authority; const prediction = this.#options.prediction; if (authority === undefined || prediction === undefined) return undefined; + const connection = authority.connection; + const tickRate = connection.welcome?.tickRate || 30; + const dt = dtMs ?? this.#options.inputDtMs ?? 1000 / tickRate; + const carry = this.#carry; + if (carry !== undefined) { + // Handoff in flight: keep predicting what is drawn. + carry.fields = this.#predictor!.step(carry.fields, input, dt); + if (!connection.canSendInput()) { + carry.unsent.push({ input, dtMs: dt }); + return undefined; + } + } + return this.#send(authority, input, dt); + } + + #send(authority: Zone, input: TInput, dtMs: number): number | undefined { const connection = authority.connection; if (!connection.canSendInput()) return undefined; const context = this.inputContext(authority)!; - const seq = connection.sendInput(prediction.encodeInput(input, context), context.clientTick); + const seq = connection.sendInput(this.#options.prediction!.encodeInput(input, context), context.clientTick); if (seq === undefined) return undefined; - const tickRate = connection.welcome?.tickRate || 30; - this.#predictor!.record(seq, input, dtMs ?? this.#options.inputDtMs ?? 1000 / tickRate); + this.#predictor!.record(seq, input, dtMs); return seq; } @@ -361,7 +394,24 @@ export class GameClient { this.#predictionEnabled && predictor?.active === true && this.#authority !== undefined ? predictor.entityId : undefined; - if (controlled !== undefined) this.#handoffBridge = undefined; + let carry = this.#carry; + if (controlled !== undefined) { + this.#handoffBridge = undefined; + if (carry !== undefined && carry.zoneId === this.#authority?.zoneId) { + // The target bound the entity: ease from what was drawn. + predictor!.easeFrom(carry.fields, now); + this.#carry = carry = undefined; + } + } + if (carry !== undefined) { + this.#handoffBridge = { + ...carry.base, + key: `${carry.zoneId}:${carry.base.id}`, + zoneId: carry.zoneId, + fields: { ...carry.base.fields, ...carry.fields }, + stale: false + }; + } const gone = this.#handedOff; let goneSeen = false; let mine: RenderedEntity | undefined; @@ -513,7 +563,7 @@ export class GameClient { if (id !== undefined) { const entity = zone.replica.get(id); if (entity !== undefined && !this.#predictor.active) { - this.#predictor.bind(id, zone.replica.schema?.byId.get(entity.kind), entity.fields); + this.#predictor.bind(id, zone.replica.schema?.byId.get(entity.kind), entity.fields, delta.ackSeq); } const correction = this.#predictor.reconcile( delta.ackSeq, @@ -556,9 +606,13 @@ export class GameClient { const to = message.targetZone; this.#emitter.emit('handoff', { from, to, message }); const last = this.#lastControlled; + this.#carry = undefined; if (last !== undefined && last.zoneId === from) { this.#handoffBridge = { ...last, stale: false }; this.#handedOff = { zoneId: from, id: last.id }; + const predicted = this.#predictor?.predicted(); + const fields = predicted === undefined ? undefined : this.#options.prediction?.handoffFrame?.(predicted, from, to); + if (fields !== undefined) this.#carry = { zoneId: to, base: last, fields, unsent: [] }; } this.#controlledOverride = undefined; this.#predictor?.bind(undefined); @@ -591,6 +645,7 @@ export class GameClient { if (this.#closed || this.#authority !== target) return; this.#emitter.emit('handoffFailed', { from, to, stage, reason }); this.#handoffBridge = undefined; + this.#carry = undefined; connection.close({ leave: false }); // Keep the zone's replica rendering while the next attempt runs. this.#retire(target, true); @@ -609,6 +664,12 @@ export class GameClient { connection.on('welcome', () => { if (done) return; finish(); + // Inputs predicted while the handoff was in flight go to the + // target (recorded, so the target's state replays them). + const carry = this.#carry; + if (carry !== undefined && carry.zoneId === zoneId) { + for (const { input, dtMs } of carry.unsent.splice(0)) this.#send(target, input, dtMs); + } const off = connection.on('delta', () => { off(); this.#emitter.emit('handoffComplete', { from, to: zoneId }); diff --git a/js/src/game/prediction.ts b/js/src/game/prediction.ts index 5f2b2982..2b1467b0 100644 --- a/js/src/game/prediction.ts +++ b/js/src/game/prediction.ts @@ -107,17 +107,59 @@ export class Predictor { } /** - * Bind (or rebind) the controlled entity. Clears pending inputs: they - * belonged to the previous binding or session. + * Bind (or rebind) the controlled entity. Switching from one entity to + * another clears pending inputs (they belonged to the previous binding); + * inputs recorded while no entity or this same entity was bound belong + * to it and are replayed onto `authoritative` (those after `ackSeq`, the + * last input `authoritative` includes, when known). */ - bind(entityId: number | undefined, kind?: GameKindDef, authoritative?: EntityFields): void { + bind(entityId: number | undefined, kind?: GameKindDef, authoritative?: EntityFields, ackSeq?: number): void { + if (entityId === undefined || (this.#entityId !== undefined && this.#entityId !== entityId)) { + this.#pending = []; + this.#lastAck = 0; + } + if (ackSeq !== undefined && ackSeq > this.#lastAck) this.#lastAck = ackSeq; + this.#pending = this.#pending.filter((p) => p.seq > this.#lastAck); this.#entityId = entityId; this.#kind = entityId === undefined ? undefined : kind; - this.#pending = []; this.#offsets.clear(); - this.#lastAck = 0; this.#authoritative = authoritative === undefined ? undefined : { ...authoritative }; - this.#predicted = authoritative === undefined ? undefined : { ...authoritative }; + let predicted: Record | undefined = authoritative === undefined ? undefined : { ...authoritative }; + if (predicted !== undefined) for (const p of this.#pending) predicted = this.#step(predicted, p.input, p.dtMs); + this.#predicted = predicted; + } + + /** + * One predicted step of `state` without recording it (e.g. continuing a + * prediction through a handoff, before the target knows the entity). + */ + step(state: EntityFields, input: TInput, dtMs: number): EntityFields { + return this.#step({ ...state }, input, dtMs); + } + + /** + * Show `shown` now and ease into the prediction (each `ease` field by its + * policy), e.g. what was drawn before a handoff rebound the entity. + */ + easeFrom(shown: EntityFields, now: number): void { + const predicted = this.#predicted; + const kind = this.#kind; + if (predicted === undefined || kind === undefined) return; + const epsilon = this.#options.epsilon ?? 1e-6; + for (const field of kind.fields) { + const policy = this.#policy(field.name); + const from = shown[field.name]; + const to = predicted[field.name]; + if (policy.mode !== 'ease' || typeof from !== 'number' || typeof to !== 'number') continue; + 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) { + this.#offsets.set(field.name, { value, start: now, durationMs: duration, angle }); + } else { + this.#offsets.delete(field.name); + } + } } /** New session (Welcome): sequence numbers restart, pending inputs are void. */ diff --git a/js/tests/game-replica-prediction.test.mjs b/js/tests/game-replica-prediction.test.mjs index 98305a5a..cd7d23ff 100644 --- a/js/tests/game-replica-prediction.test.mjs +++ b/js/tests/game-replica-prediction.test.mjs @@ -102,6 +102,20 @@ test('prediction replays unacked inputs over authoritative state', () => { assert.deepEqual(predictor.pending.map((p) => p.seq), [4, 5]); }); +test('inputs recorded before the entity is known are replayed when it binds', () => { + const predictor = new Predictor({ predict: moverPredict }); + predictor.bind(7); + predictor.record(1, 10, 100); + predictor.record(2, 10, 100); + assert.equal(predictor.active, false); + predictor.bind(7, ARENA_SCHEMA.byId.get(1), { x: 0, y: 0, yaw: 0, hp: 100, name: 'p' }); + close(predictor.predicted().x, 2, 1e-9, 'both replayed'); + // Another entity: the inputs were not its. + predictor.bind(8, ARENA_SCHEMA.byId.get(1), { x: 0, y: 0, yaw: 0, hp: 100, name: 'p' }); + close(predictor.predicted().x, 0, 1e-9); + assert.deepEqual(predictor.pending, []); +}); + test('ease blends the divergence out over time while snap jumps', () => { const authoritative = { x: 1, y: 0, yaw: 0, hp: 80, name: 'p' }; // server hit a wall const eased = boundPredictor({ mode: 'ease', durationMs: 200 }); diff --git a/js/tests/game-world.test.mjs b/js/tests/game-world.test.mjs index 4718c2e6..228549dc 100644 --- a/js/tests/game-world.test.mjs +++ b/js/tests/game-world.test.mjs @@ -235,6 +235,65 @@ test('handoff holds the predicted entity until the target binds it (no source co client.close(); }); +test('handoffFrame: the prediction continues through a handoff in the target frame, inputs reach the target, the switch eases', async () => { + const network = new FakeNetwork(); + const scheduler = new FakeScheduler(); + const inputs = { z1: [], z2: [] }; + let welcomeZ2 = false; + network.onMessage = (conn, m) => { + if (m.type === 'Hello' && (conn.zoneId !== 'z2' || welcomeZ2)) conn.send(welcome({ zoneId: conn.zoneId })); + if (m.type === 'Input') inputs[conn.zoneId].push(m.seq); + }; + // Zone-local x; z2 starts 10 m further on (its x = world x − 10). + const client = new GameClient({ + url: (zone) => `ws://game.test/zones/${zone}`, + ticket: ({ zoneId }) => `fresh:${zoneId}`, + socket: network.factory, + scheduler, + pingIntervalMs: 60_000, + handoffRetainMs: 100, + handoffTimeoutMs: 2000, + prediction: { + encodeInput: (dx) => new Uint8Array([dx & 0xff]), + predict: (s, dx, dt) => ({ x: s.x + (dx * dt) / 1000 }), + controlledEntity: () => 1, + handoffFrame: (fields, from, to) => (from === 'z1' && to === 'z2' ? { ...fields, x: fields.x - 10 } : undefined) + } + }); + const world = (e) => (e.zoneId === 'z2' ? 10 : 0) + e.fields.x; + const own = () => client.render().filter((e) => e.id === 1); + await client.connect('z1'); + network.last('z1').send(delta({ creates: [avatar(1, 9.5)] })); + client.sendInput(10, 100); // +1 → 10.5 (lost: z1 hands off before applying it) + assert.ok(Math.abs(world(own()[0]) - 10.5) < 1e-3, `predicted: ${JSON.stringify(own())}`); + + network.last('z1').send({ type: 'Handoff', targetZone: 'z2', transferTicket: 'transfer:t1' }); + await scheduler.advance(10); + // Not welcomed yet: predicted on in z2's frame, the inputs kept. + client.sendInput(10, 100); + client.sendInput(10, 100); + let shown = own(); + assert.deepEqual(shown.map((e) => [e.key, e.predicted]), [['z2:1', true]]); + assert.ok(Math.abs(world(shown[0]) - 12.5) < 1e-3, `continued: ${world(shown[0])}`); + assert.deepEqual(inputs.z2, []); + + // Welcomed: the kept inputs go to the target. + welcomeZ2 = true; + network.last('z2').close(); + await scheduler.advance(2000); + assert.equal(inputs.z2.length >= 2, true, `sent to z2: ${inputs.z2}`); + // The target admitted the entity where z1 last had it (9.5 → z2's −0.5), + // without the lost input; it has not applied the two kept ones yet. + network.last('z2').send(delta({ ackSeq: 0, creates: [avatar(1, -0.5)] })); + network.last('z1').send(delta({ serverTick: 101, updates: [] })); + shown = own(); + assert.deepEqual(shown.map((e) => [e.key, e.predicted]), [['z2:1', true]]); + assert.ok(Math.abs(world(shown[0]) - 12.5) < 1e-3, `eases from what was shown: ${world(shown[0])}`); + await scheduler.advance(1000); + assert.ok(Math.abs(world(own()[0]) - 11.5) < 1e-3, `converged on the target's prediction: ${world(own()[0])}`); + client.close(); +}); + test('a silent handoff target times out into the return path', async () => { const network = new FakeNetwork(); const scheduler = new FakeScheduler(); From bbc15357b97b01562e3375deeb75c2d46241dece Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 22:40:09 -0500 Subject: [PATCH 10/87] feat(game)!: one continuous Mob Pit world across rooms Rooms sit at fixed world places (room (x, y) at (x, y) x 40 m) and a doorway is a passage: pit_step walks through the gap past the wall line, the room hands the player over 0.5 m past it (hysteresis) and the next room admits it at the same world point with its velocity (carried MoveState in the target frame) instead of 2 m inside the opposite wall. Player x/y widen to +-22 m on the wire. Both pages draw the world: the Vite page builds the 5x5 rooms around yours from pitRoom (biome floor, walls with doorways, pillars) under a floating origin; the SvelteKit canvas follows your world position over the 3x3 rooms around. Both predict through the handoff (handoffFrame). Tests: rooms test checks the arrival world point; headless and Playwright walks assert the own world position never moves more than one tick across a doorway. [[tasks/gr-p41-mobpit-rooms-zones]] --- examples/game/README.md | 22 +- .../client/src/generated/mobpit/constants.ts | 3 + .../client/src/generated/mobpit/events.ts | 3 + .../client/src/generated/mobpit/schema.ts | 8 +- .../client/src/generated/mobpit/surface.json | 31 +- examples/game/client/src/mobpit/client.ts | 57 +++- examples/game/client/src/mobpit/main.ts | 274 ++++++++++++++---- examples/game/client/tests/helpers/mobpit.mjs | 44 ++- .../client/tests/mobpit-headless.test.mjs | 31 +- examples/game/server/rooms/src/zone.rs | 122 +++++--- examples/game/server/rooms/tests/rooms.rs | 57 +++- examples/game/server/sim/src/arena.rs | 17 +- examples/game/server/sim/src/mobpit.rs | 239 ++++++++++++--- .../src/lib/generated/mobpit/constants.ts | 3 + .../src/lib/generated/mobpit/events.ts | 3 + .../src/lib/generated/mobpit/schema.ts | 8 +- .../src/lib/generated/mobpit/surface.json | 31 +- examples/game/sveltekit/src/lib/pit/loop.ts | 165 ++++++++--- .../sveltekit/src/routes/pit/+page.svelte | 6 +- examples/game/sveltekit/tests/pit.test.mjs | 32 +- 20 files changed, 905 insertions(+), 251 deletions(-) diff --git a/examples/game/README.md b/examples/game/README.md index 47f69ec3..0d7a5314 100644 --- a/examples/game/README.md +++ b/examples/game/README.md @@ -505,7 +505,10 @@ 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 middle of each wall (go beside the pillar on its axis) — into the -neighbouring room. Mobs wander near home, aggro within 7 m, chase, stop and +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 +are drawn from their seeded layout, so you see into the next room, and +going through a doorway just carries on walking — no scene swap, no jump. Mobs wander near home, aggro within 7 m, chase, stop and show a red **telegraph ring** that fills for 0.8 s, then hit everyone inside it. Grunts (green, 10 XP), brutes (purple, 25 XP) and elites (red, 60 XP) respawn 4 s after dying. Level `n` starts at `40·(n−1)²` XP. @@ -518,10 +521,19 @@ the same Rust compiled to WebAssembly (`createMobpitPures().pitRoom`) for the floor colour and the minimap; nothing static is replicated. Seed: `MOBPIT_SEED` (worker var). -**Leaving and coming back.** A doorway hands the player over -(`ctx.handoff`, carrying where along the wall it went through); the room -signs a single-use transfer ticket and the SDK reconnects to the next -room's object, wherever it runs. A room that empties writes its snapshot +**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 +room hands you over (`ctx.handoff`, carrying your movement state in the +next room's frame: the same world point and velocity); the room signs a +single-use transfer ticket and the SDK reconnects to the next room's +object, wherever it runs. The client keeps predicting through the +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 (`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, diff --git a/examples/game/client/src/generated/mobpit/constants.ts b/examples/game/client/src/generated/mobpit/constants.ts index e01248b1..2b523df3 100644 --- a/examples/game/client/src/generated/mobpit/constants.ts +++ b/examples/game/client/src/generated/mobpit/constants.ts @@ -21,6 +21,9 @@ export const PIT_WORLD: PitWorld = { defaultSeed: 186474221, half: 20, doorHalf: 2.5, + roomSize: 40, + passage: 2, + hysteresis: 0.5, maxRoom: 1000000, biomes: ['highlands', 'steppe', 'forest', 'meadow'] }; diff --git a/examples/game/client/src/generated/mobpit/events.ts b/examples/game/client/src/generated/mobpit/events.ts index a3f5878b..b9815227 100644 --- a/examples/game/client/src/generated/mobpit/events.ts +++ b/examples/game/client/src/generated/mobpit/events.ts @@ -55,6 +55,9 @@ export interface PitWorld { readonly defaultSeed: number; readonly half: number; readonly doorHalf: number; + readonly roomSize: number; + readonly passage: number; + readonly hysteresis: number; readonly maxRoom: number; readonly biomes: readonly string[]; } diff --git a/examples/game/client/src/generated/mobpit/schema.ts b/examples/game/client/src/generated/mobpit/schema.ts index 8019b0b3..cf619af1 100644 --- a/examples/game/client/src/generated/mobpit/schema.ts +++ b/examples/game/client/src/generated/mobpit/schema.ts @@ -15,9 +15,9 @@ export type KindName = keyof typeof KIND; /** `player` (kind 1, priority 2). */ export interface PlayerFields { - /** `quant [-20, 19.999847] / 18 bits` */ + /** `quant [-22, 21.999832] / 18 bits` */ readonly x: number; - /** `quant [-20, 19.999847] / 18 bits` */ + /** `quant [-22, 21.999832] / 18 bits` */ readonly y: number; /** `quant [-16, 15.9921875] / 12 bits` */ readonly vx: number; @@ -164,8 +164,8 @@ export function pitFields(entity: { readonly fields: EntityFields }): PitFields /** 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 }, + x: { type: 'quant', min: -22, max: 21.999832153320313, bits: 18 }, + y: { type: 'quant', min: -22, max: 21.999832153320313, 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' }, diff --git a/examples/game/client/src/generated/mobpit/surface.json b/examples/game/client/src/generated/mobpit/surface.json index 6c0a39c7..4299b39e 100644 --- a/examples/game/client/src/generated/mobpit/surface.json +++ b/examples/game/client/src/generated/mobpit/surface.json @@ -53,7 +53,10 @@ "default_seed": 186474221, "door_half": 2.5, "half": 20.0, - "max_room": 1000000 + "hysteresis": 0.5, + "max_room": 1000000, + "passage": 2.0, + "room_size": 40.0 } } ], @@ -330,8 +333,8 @@ "priority": 1.0, "type": { "bits": 18, - "max": 19.999847, - "min": -20.0, + "max": 21.999832, + "min": -22.0, "type": "quant" } }, @@ -344,8 +347,8 @@ "priority": 1.0, "type": { "bits": 18, - "max": 19.999847, - "min": -20.0, + "max": 21.999832, + "min": -22.0, "type": "quant" } }, @@ -854,6 +857,24 @@ "type": "f32" } }, + { + "name": "room_size", + "type": { + "type": "f32" + } + }, + { + "name": "passage", + "type": { + "type": "f32" + } + }, + { + "name": "hysteresis", + "type": { + "type": "f32" + } + }, { "name": "max_room", "type": { diff --git a/examples/game/client/src/mobpit/client.ts b/examples/game/client/src/mobpit/client.ts index 42613f9d..64190427 100644 --- a/examples/game/client/src/mobpit/client.ts +++ b/examples/game/client/src/mobpit/client.ts @@ -11,11 +11,16 @@ * Walking through a doorway, the room hands the player to the next room * (`Handoff`), and the SDK reconnects there with the transfer ticket. * + * One continuous world: room `(x, y)` sits at world `(x, y) × ROOM_SIZE` + * and entity positions are room-local, so `worldOf` places anything from + * any room (the doorway arrival is the same world point, see + * `game_sim::mobpit`). + * * 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 { GameSocketFactory } from '@hops-ops/distributed/game'; +import type { EntityFields, GameSocketFactory, RenderedEntity } from '@hops-ops/distributed/game'; import * as pit from '../generated/mobpit/index.ts'; import type { DevApi } from '../shared/dev-api.ts'; @@ -29,6 +34,34 @@ export function parseRoom(zone: string): [number, number] | undefined { return m === null ? undefined : [Number(m[1]), Number(m[2])]; } +/** Room edge length (m): room `(x, y)` is centred at world `(x, y) × ROOM_SIZE`. */ +export const ROOM_SIZE = pit.PIT_WORLD.roomSize; + +/** World position (m) of a room-local point of `zone` (undefined for a non-room zone). */ +export function toWorldXY(zone: string, x: number, y: number): { x: number; y: number } | undefined { + const room = parseRoom(zone); + return room === undefined ? undefined : { x: room[0] * ROOM_SIZE + x, y: room[1] * ROOM_SIZE + y }; +} + +/** World position (m) of a rendered entity (every Mob Pit kind has local `x` / `y`). */ +export function worldOf(entity: RenderedEntity): { x: number; y: number } | undefined { + const { x, y } = entity.fields as { x?: unknown; y?: unknown }; + return typeof x === 'number' && typeof y === 'number' ? toWorldXY(entity.zoneId, x, y) : undefined; +} + +/** + * The own player's predicted fields re-expressed in the next room's frame + * (the same world point): lets the SDK keep predicting through a doorway + * handoff instead of holding the player still (`handoffFrame`). + */ +export function pitHandoffFrame(fields: EntityFields, from: string, to: string): EntityFields | undefined { + const a = parseRoom(from); + const b = parseRoom(to); + const { x, y } = fields as { x?: unknown; y?: unknown }; + if (a === undefined || b === undefined || typeof x !== 'number' || typeof y !== 'number') return undefined; + return { ...fields, x: x + (a[0] - b[0]) * ROOM_SIZE, y: y + (a[1] - b[1]) * ROOM_SIZE }; +} + /** `[x, y]` → `pit..`. */ export function roomZone(x: number, y: number): string { return `pit.${x}.${y}`; @@ -124,6 +157,10 @@ export type MobPitClient = pit.MobpitClient & profile(): pit.Profile | undefined; /** The room the player is in (authority zone). */ room(): string | undefined; + /** The own player as rendered now (predicted; held through a handoff). */ + me(): RenderedEntity | undefined; + /** World position (m) of the own player as rendered now. */ + position(): { x: number; y: number } | undefined; /** Kills by me whose fact has not settled yet (key → event). */ unsaved: ReadonlyMap; /** The rooms' generated contents (the Rust generator as WASM). */ @@ -145,8 +182,10 @@ export function createMobPitClient(options: MobPitClientOptions): MobPitClient { ticket: options.api.ticketSource(), socket: options.socket, pingIntervalMs: 500, - inputDtMs: 1000 / 30 - // Prediction: the generated default, `pit_step` compiled to WASM. + inputDtMs: 1000 / 30, + // Prediction: the generated default, `pit_step` compiled to WASM, + // carried on through doorways in the next room's frame. + prediction: { handoffFrame: pitHandoffFrame } }); const hello = latest( (h) => typed.on('hello', h), @@ -159,11 +198,23 @@ export function createMobPitClient(options: MobPitClientOptions): MobPitClient { }); typed.on('saved', (saved) => unsaved.delete(saved.key)); const pures = pit.createMobpitPures(); + // 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)); + return rendered.find((e) => e.predicted) ?? rendered.find((e) => e.id === id && e.zoneId === typed.client.authority?.zoneId) ?? rendered.find((e) => e.id === id); + }; return { ...typed, hello: hello.get, profile: profile.get, room: () => typed.client.authority?.zoneId, + me, + position: () => { + const entity = me(); + return entity === undefined ? undefined : worldOf(entity); + }, unsaved, pures, ready: Promise.all([hello.ready, typed.prediction?.ready, pures.ready]).then(([h]) => h) diff --git a/examples/game/client/src/mobpit/main.ts b/examples/game/client/src/mobpit/main.ts index 6cfd0624..0c12e16a 100644 --- a/examples/game/client/src/mobpit/main.ts +++ b/examples/game/client/src/mobpit/main.ts @@ -2,8 +2,13 @@ * Mob Pit page: Babylon scene over `createMobPitClient`. * * What to look at: rooms are celld Durable Objects on a near-infinite - * seeded grid. Walk through a doorway (the gaps in the middle of each wall) - * into the next room; the minimap (bottom right) shows every known room + * seeded grid, laid out as one continuous world (room `(x, y)` at + * `(x, y) × ROOM_SIZE`): the rooms around yours are drawn from the seeded + * layout (`pitRoom`, Rust as WASM: biome floor, walls, doorways, pillars), + * so you see into the next room before you go. Walk through a doorway (the + * gaps in the middle of each wall) and you are simply in the next room — + * the room hands you over at the same world point; nothing moves. A + * floating origin at your room keeps coordinates small. The minimap (bottom right) shows every known room * live — active, dormant (just left), hibernated (asleep in storage) — from * the `zone_status` read model. Come back to a room after it slept: it * wakes from its snapshot with the mobs you left (toast). Kill a mob @@ -12,9 +17,12 @@ * node and isolate run the room — celld decides, the game never does. */ 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 { 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'; @@ -26,7 +34,6 @@ import { isPillar, isPit, mobFields, - pillarFields, pitFields, playerFields, type PitFields, @@ -35,13 +42,14 @@ import { import { DevApi } from '../shared/dev-api.ts'; import { Hud } from '../shared/hud.ts'; import { cameraRelative, InputSampler, toAxis } from '../shared/input.ts'; -import { createStage, material, toWorld } from '../shared/scene.ts'; +import { createStage, material } from '../shared/scene.ts'; import { createBoardWatcher, createMobPitClient, DEFAULT_ZONE, MobPitApi, parseRoom, + ROOM_SIZE, roomZone, TIERS, type BoardSnapshot, @@ -50,7 +58,12 @@ import { } from './client.ts'; /** The Arena floor, generated from the simulation's constants (`game_sim::arena::arena_floor`). */ -const { half: HALF, playerRadius: PLAYER_RADIUS } = ARENA_FLOOR; +const { half: HALF, playerRadius: PLAYER_RADIUS, pillars: PILLARS } = ARENA_FLOOR; +/** Rooms drawn around yours (2 → 5×5). */ +const ROOM_VIEW = 2; +/** Wall thickness and height (m), drawn just outside each room's floor. */ +const WALL_T = 0.3; +const WALL_H = 0.6; /** Swing cone, from the server's rules: reach to a mob's edge, plus the body. */ const { attackRange, attackHalfAngle: SWING_HALF, swingFrames: SWING_FRAMES } = PIT_RULES; const ATTACK_REACH = attackRange + PLAYER_RADIUS; @@ -230,32 +243,162 @@ 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)); -// Doorways: a glowing gate in the middle of each wall, labelled with the -// room behind it; the floor takes the room's biome colour. -const doorMat = material(scene, 'door', '#4dd0e1', 0.55); -doorMat.emissiveColor = Color3.FromHexString('#4dd0e1'); +// ---- 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. +for (const mesh of [...scene.meshes]) if (mesh.name === 'floor' || mesh.name === 'wall') mesh.dispose(); +type Room = readonly [number, number]; +let origin: Room = parseRoom(zone) ?? [0, 0]; + +/** Scene position of a room-local point of room `room`. */ +function place(room: Room, x: number, y: number, height = 0): Vector3 { + return new Vector3((room[0] - origin[0]) * ROOM_SIZE + x, height, (room[1] - origin[1]) * ROOM_SIZE + y); +} + +/** Scene position of a room-local point of zone `zoneId` (the origin room if it is not a room). */ +function placeIn(zoneId: string, x: number, y: number, height = 0): Vector3 { + return place(parseRoom(zoneId) ?? origin, x, y, height); +} + +/** Room templates, instanced per room: floor per biome, wall segment, doorway gate, pillar. */ +const templates = (() => { + const px = 1024; + const texture = new DynamicTexture('room-grid', { width: px, height: px }, scene, true); + const ctx = texture.getContext() as CanvasRenderingContext2D; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, px, px); + const lines = HALF * 2; + for (let i = 0; i <= lines; i += 1) { + const p = (i / lines) * px; + ctx.strokeStyle = i % 5 === 0 ? '#9aa0aa' : '#c8ccd2'; + ctx.lineWidth = i % 5 === 0 ? 3 : 1.5; + ctx.beginPath(); + ctx.moveTo(p, 0); + ctx.lineTo(p, px); + ctx.moveTo(0, p); + ctx.lineTo(px, p); + ctx.stroke(); + } + texture.update(); + const floors = BIOME_COLOR.map((color, biome) => { + const m = material(scene, `floor-${biome}`, color); + m.diffuseTexture = texture; + m.diffuseColor = Color3.FromHexString(color).scale(0.42); + m.specularColor = Color3.Black(); + const ground = MeshBuilder.CreateGround(`floor-${biome}`, { width: HALF * 2, height: HALF * 2 }, scene); + ground.material = m; + ground.isPickable = false; + ground.setEnabled(false); + return ground; + }); + const wallMat = material(scene, 'room-wall', '#4b5668'); + const segment = HALF - PIT_WORLD.doorHalf; + const wall = MeshBuilder.CreateBox('room-wall', { width: segment, depth: WALL_T, height: WALL_H }, scene); + wall.material = wallMat; + wall.isPickable = false; + wall.setEnabled(false); + const doorMat = material(scene, 'door', '#4dd0e1', 0.55); + doorMat.emissiveColor = Color3.FromHexString('#4dd0e1'); + const gate = MeshBuilder.CreateBox('door', { width: PIT_WORLD.doorHalf * 2, depth: 0.3, height: 0.08 }, scene); + gate.material = doorMat; + gate.isPickable = false; + gate.setEnabled(false); + const pillar = MeshBuilder.CreateCylinder('room-pillar', { diameter: 1, height: 2.4, tessellation: 24 }, scene); + pillar.material = mats.pillar; + pillar.isPickable = false; + pillar.setEnabled(false); + return { floors, wall, segment, gate, pillar }; +})(); + +type Chunk = { room: Room; node: TransformNode; biome: number | undefined; seed: number }; +const chunks = new Map(); + +/** One room's static geometry under a node at the room's centre. */ +function createChunk(room: Room): Chunk { + const key = roomZone(room[0], room[1]); + const node = new TransformNode(`room-${key}`, scene); + const seed = pit.hello()?.seed ?? PIT_WORLD.defaultSeed; + const biome = pit.pures.loaded() ? pit.pures.pitRoom({ seed, x: room[0], y: room[1] }).biome : undefined; + const add = (mesh: Mesh, name: string, x: number, y: number, height: number, turn = 0) => { + const instance = mesh.createInstance(`${key}-${name}`); + instance.parent = node; + instance.position.set(x, height, y); + instance.rotation.y = turn; + instance.isPickable = false; + return instance; + }; + add(templates.floors[biome ?? 0]!, 'floor', 0, 0, 0); + // Each wall: two segments either side of its doorway gap, just outside + // the floor (the neighbour's wall stands right behind it). + const off = PIT_WORLD.doorHalf + templates.segment / 2; + const out = HALF + WALL_T / 2; + 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(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); + }); + return { room, node, biome, seed }; +} + +/** Keep the rooms within `ROOM_VIEW` of the origin built and placed. */ +function syncChunks(): void { + const wanted = new Set(); + for (let dy = -ROOM_VIEW; dy <= ROOM_VIEW; dy += 1) { + for (let dx = -ROOM_VIEW; dx <= ROOM_VIEW; dx += 1) { + const room: Room = [origin[0] + dx, origin[1] + dy]; + const key = roomZone(room[0], room[1]); + wanted.add(key); + const chunk = chunks.get(key); + // Built before the world module loaded or the seed was known: rebuild. + const seed = pit.hello()?.seed ?? PIT_WORLD.defaultSeed; + if (chunk !== undefined && chunk.seed === seed && (chunk.biome !== undefined || !pit.pures.loaded())) continue; + chunk?.node.dispose(); + chunks.set(key, createChunk(room)); + } + } + for (const [key, chunk] of chunks) { + if (!wanted.has(key)) { + chunk.node.dispose(); + chunks.delete(key); + continue; + } + chunk.node.position.copyFrom(place(chunk.room, 0, 0)); + } +} + +/** Re-centre on `to` without a visible move: the camera shifts with the world. */ +function moveOrigin(to: Room): void { + if (to[0] === origin[0] && to[1] === origin[1]) return; + const shift = new Vector3((to[0] - origin[0]) * ROOM_SIZE, 0, (to[1] - origin[1]) * ROOM_SIZE); + origin = to; + stage.camera.setTarget(stage.camera.target.subtract(shift), false, false, true); + syncChunks(); +} +syncChunks(); +pit.pures.ready.then(syncChunks, () => undefined); +pit.on('hello', () => syncChunks()); + +// Doorway labels of your room: anchors at its doors (the origin room, so +// they never move), named after the room behind each. const DOORS: { dx: number; dy: number }[] = [ { dx: 1, dy: 0 }, { dx: -1, dy: 0 }, { dx: 0, dy: 1 }, { dx: 0, dy: -1 } ]; -const doorMeshes = DOORS.map(({ dx, dy }, i) => { - const gate = MeshBuilder.CreateBox( - `door-${i}`, - { width: dx === 0 ? PIT_WORLD.doorHalf * 2 : 0.3, depth: dy === 0 ? PIT_WORLD.doorHalf * 2 : 0.3, height: 0.2 }, - scene - ); - gate.material = doorMat; - gate.isPickable = false; - gate.position.copyFrom(toWorld(dx * HALF, dy * HALF, 0.1)); - return gate; +const doorAnchors = DOORS.map(({ dx, dy }, i) => { + const anchor = MeshBuilder.CreateBox(`door-anchor-${i}`, { size: 0.01 }, scene); + anchor.isVisible = false; + anchor.isPickable = false; + anchor.position.set(dx * HALF, 0.1, dy * HALF); + return anchor; }); -const biomeFloor = MeshBuilder.CreateGround('biome', { width: HALF * 2, height: HALF * 2 }, scene); -biomeFloor.position.y = 0.01; -biomeFloor.isPickable = false; -const biomeMat = material(scene, 'biome', '#3f7d4e', 0.18); -biomeFloor.material = biomeMat; let shownRoom = ''; function showRoom(zone: string): void { @@ -263,20 +406,18 @@ function showRoom(zone: string): void { if (at === undefined || zone === shownRoom) return; shownRoom = zone; DOORS.forEach(({ dx, dy }, i) => { - stage.labels.set(`door-${i}`, doorMeshes[i]!, `→ ${roomZone(at[0] + dx, at[1] + dy)}`, 0.6, 'door'); + stage.labels.set(`door-${i}`, doorAnchors[i]!, `→ ${roomZone(at[0] + dx, at[1] + dy)}`, 0.6, 'door'); }); const seed = pit.hello()?.seed ?? PIT_WORLD.defaultSeed; if (pit.pures.loaded()) { const room = pit.pures.pitRoom({ seed, x: at[0], y: at[1] }); - biomeMat.diffuseColor = Color3.FromHexString(BIOME_COLOR[room.biome] ?? '#3f7d4e'); hud.row('Room', `${zone} · ${PIT_WORLD.biomes[room.biome] ?? '?'} · danger ${room.danger} · ${room.mobs.length} mobs`); } } 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 PillarVisual = { kind: 'pillar'; body: Mesh }; -type Visual = PlayerVisual | MobVisual | PillarVisual; +type Visual = PlayerVisual | MobVisual; const visuals = new Map(); function flatDisc(id: string, radius: number, mat: StandardMaterial, arc = 1): Mesh { @@ -323,11 +464,6 @@ function visualFor(entity: RenderedEntity, mine: boolean): Visual { if (visual !== undefined) return visual; if (isMob(entity)) { visual = createMob(entity); - } else if (isPillar(entity)) { - const { r } = pillarFields(entity); - const body = MeshBuilder.CreateCylinder(entity.key, { diameter: r * 2, height: 2.4, tessellation: 24 }, scene); - body.material = mats.pillar; - visual = { kind: 'pillar', body }; } else { visual = createPlayer(entity, mine); } @@ -340,37 +476,42 @@ function dispose(visual: Visual): void { } // ---- kill / saved popups (DOM labels anchored to invisible meshes) ---- -type Popup = { mesh: Mesh; until: number }; +type Popup = { mesh: Mesh; until: number; zone: string; x: number; y: number }; const popups = new Map(); let popupSeq = 0; -function popup(key: string, x: number, y: number, text: string, className: string, ttlMs: number): void { +/** The room a kill happened in, from its fact key (`…:pit..:…`). */ +function killRoom(key: string, fallback: string): string { + return /:(pit\.-?\d+\.-?\d+):/.exec(key)?.[1] ?? fallback; +} + +function popup(key: string, zoneId: string, x: number, y: number, text: string, className: string, ttlMs: number): void { let p = popups.get(key); if (p === undefined) { const mesh = MeshBuilder.CreateBox(`popup-${popupSeq++}`, { size: 0.01 }, scene); mesh.isVisible = false; - mesh.position.copyFrom(toWorld(x, y, 1.2)); - p = { mesh, until: 0 }; + p = { mesh, until: 0, zone: killRoom(key, zoneId), x, y }; + mesh.position.copyFrom(placeIn(p.zone, x, y, 1.2)); popups.set(key, p); } p.until = performance.now() + ttlMs; stage.labels.set(`popup:${key}`, p.mesh, text, 0.6, `pit-pop ${className}`); } -pit.on('kill', (kill) => { +pit.on('kill', (kill, zoneId) => { const mine = kill.killer === pit.hello()?.entity; const tier = TIERS[kill.tier]?.name ?? 'Mob'; - if (mine) popup(kill.key, kill.x, kill.y, `${tier} slain · saving…`, 'pending', 60_000); - else popup(kill.key, kill.x, kill.y, `${kill.killerName} slew a ${tier}`, 'other', 2000); + 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); }); let savedCount = 0; -pit.on('saved', (saved) => { +pit.on('saved', (saved, zoneId) => { if (saved.ok) { savedCount += 1; const waited = saved.waitedMs >= 200 ? ` (${(saved.waitedMs / 1000).toFixed(1)} s)` : ''; - popup(saved.key, saved.x, saved.y, `+${saved.xp} XP saved ✓${waited}`, 'saved', 2500); + popup(saved.key, zoneId, saved.x, saved.y, `+${saved.xp} XP saved ✓${waited}`, 'saved', 2500); } else { - popup(saved.key, saved.x, saved.y, `save failed: ${saved.error ?? '?'}`, 'failed', 4000); + popup(saved.key, zoneId, saved.x, saved.y, `save failed: ${saved.error ?? '?'}`, 'failed', 4000); } }); pit.on('died', (d) => { @@ -451,42 +592,47 @@ function sendCommandFrame(): void { // ---- frame ---- stage.onFrame((now, dtMs) => { commandFrames.update(now, sendCommandFrame); + const here = pit.room(); + const hereRoom = here === undefined ? undefined : parseRoom(here); + if (hereRoom !== undefined) moveOrigin(hereRoom); const hello = pit.hello(); const authority = client.authority; const rendered = client.render(); const seen = new Set(); - let me: { x: number; y: number; hp: number; dead: boolean } | undefined; + let me: { at: Vector3; hp: number; dead: boolean } | undefined; let stats: PitFields | undefined; for (const entity of rendered) { if (isPit(entity)) { - stats = pitFields(entity); + if (entity.zoneId === here) stats = pitFields(entity); continue; } - const mine = authority !== undefined && hello !== undefined && entity.zoneId === authority.zoneId && entity.id === hello.entity; + // Pillars are static: every room's are drawn with its chunk. + if (isPillar(entity)) continue; + // Yours: predicted, or the SDK's hold of it during a doorway handoff. + const mine = + entity.predicted || (authority !== undefined && hello !== undefined && entity.zoneId === authority.zoneId && entity.id === hello.entity); + const at = (x: number, y: number, height: number) => placeIn(entity.zoneId, x, y, height); const visual = visualFor(entity, mine); seen.add(entity.key); - const visible = !entity.stale || visual.kind === 'pillar'; - if (visual.kind === 'pillar') { - const { x, y } = pillarFields(entity); - visual.body.position.copyFrom(toWorld(x, y, 1.2)); - } else if (visual.kind === 'mob') { + const visible = !entity.stale; + 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; - visual.body.position.copyFrom(toWorld(x, y, height)); + visual.body.position.copyFrom(at(x, y, height)); visual.body.rotation.y = -mob.face; const hp = mob.hp / 100; const top = height * 2 + 0.35; - visual.hpBack.position.copyFrom(toWorld(x, y, top)); - visual.hpFill.position.copyFrom(toWorld(x, y, top)); + visual.hpBack.position.copyFrom(at(x, y, top)); + visual.hpFill.position.copyFrom(at(x, y, top)); visual.hpFill.scaling.x = Math.max(0.001, hp); const tele = mob.tele / 255; const winding = mob.state === 2; visual.ring.setEnabled(visible && winding); visual.fill.setEnabled(visible && winding); if (winding) { - visual.ring.position.copyFrom(toWorld(x, y, 0.03)); - visual.fill.position.copyFrom(toWorld(x, y, 0.04)); + visual.ring.position.copyFrom(at(x, y, 0.03)); + visual.fill.position.copyFrom(at(x, y, 0.04)); visual.fill.scaling.setAll(Math.max(0.05, tele)); } visual.body.scaling.setAll(winding ? 1 + 0.15 * tele : 1); @@ -494,19 +640,19 @@ stage.onFrame((now, dtMs) => { } else { const player = playerFields(entity); const { x, y, dead, face, swing } = player; - visual.body.position.copyFrom(toWorld(x, y, 0.9)); + visual.body.position.copyFrom(at(x, y, 0.9)); visual.body.material = dead ? mats.dead : mine ? mats.me : mats.other; - visual.nose.position.copyFrom(toWorld(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)); 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(toWorld(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)); visual.blade.rotation.y = -a; visual.blade.setEnabled(visible && !dead); visual.arc.setEnabled(visible && swing > 0); if (swing > 0) { - visual.arc.position.copyFrom(toWorld(x, y, 0.05)); + visual.arc.position.copyFrom(at(x, y, 0.05)); // Disc arcs start at +x and run counter-clockwise; centre it on `face`. visual.arc.rotation.y = -(face - SWING_HALF); } @@ -514,7 +660,7 @@ stage.onFrame((now, dtMs) => { stage.labels.set(entity.key, visual.body, mine ? `${label} (you)` : label, 1.4, mine ? 'me' : ''); visual.body.setEnabled(visible); visual.nose.setEnabled(visible && !dead); - if (mine) me = { x, y, hp: player.hp, dead }; + if (mine) me = { at: visual.body.position, hp: player.hp, dead }; } } for (const [key, visual] of visuals) { @@ -524,12 +670,13 @@ stage.onFrame((now, dtMs) => { visuals.delete(key); } for (const [key, p] of popups) { + p.mesh.position.copyFrom(placeIn(p.zone, p.x, p.y, 1.2)); if (now < p.until) continue; p.mesh.dispose(); stage.labels.delete(`popup:${key}`); popups.delete(key); } - if (me !== undefined) stage.follow(me.x, me.y, dtMs); + if (me !== undefined) stage.follow(me.at.x, me.at.z, dtMs); // ---- HUD ---- hud.frame({ entities: rendered.length }); @@ -554,7 +701,6 @@ stage.onFrame((now, dtMs) => { field('pending').textContent = String(pending); 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.'; - const here = pit.room(); if (here !== undefined) showRoom(here); if (now - lastSampleAt > 250) { drawRooms(); @@ -571,6 +717,6 @@ declare global { mobpit?: unknown; } } -window.mobpit = { client, api, pitApi, pit, scene, rooms, boards }; +window.mobpit = { client, api, pitApi, pit, scene, rooms, boards, camera: stage.camera, origin: () => origin }; client.connect(zone).catch((error: unknown) => hud.row('Status', `failed: ${String(error)}`)); diff --git a/examples/game/client/tests/helpers/mobpit.mjs b/examples/game/client/tests/helpers/mobpit.mjs index 6acac3a9..2b73a521 100644 --- a/examples/game/client/tests/helpers/mobpit.mjs +++ b/examples/game/client/tests/helpers/mobpit.mjs @@ -50,31 +50,49 @@ export async function startPit(demo = 'mobpit', vars = {}) { }; } -/** Walk to the doorway on side `[dx, dy]` and push through it until the - * client's authority is another room (one command frame per tick). */ +/** + * Walk to the doorway on side `[dx, dy]` and through it (one command frame + * per tick) until the client's authority is the next room and the own + * player is predicted there again. Returns the new room and the own world + * position sampled every frame on the way (`{ t, room, x, y }`). + */ export async function walkThroughDoor(pit, [dx, dy], timeoutMs = 30_000) { const { client } = pit; const from = pit.room(); const deadline = Date.now() + timeoutMs; - while (pit.room() === from) { + const trace = []; + const settled = () => pit.room() !== from && pit.me()?.predicted === true && pit.me()?.zoneId === pit.room(); + for (let after = 0; after < 10; after += settled() ? 1 : 0) { if (Date.now() > deadline) throw new Error(`walk through door ${dx},${dy} timed out`); - const rendered = client.render(); - const meEntity = rendered.find((e) => e.id === pit.hello()?.entity && isPlayer(e) && e.zoneId === from); - const me = meEntity && playerFields(meEntity); + const me = pit.me(); + const at = pit.position(); + if (at !== undefined) trace.push({ t: Date.now(), room: me.zoneId, predicted: me.predicted, ...at }); let input = { mx: 0, my: 0, buttons: 0 }; - if (me !== undefined) { - // Beside the pillar on the door's axis, then straight out. - const tx = dx !== 0 ? dx * 21 : 2; - const ty = dy !== 0 ? dy * 21 : 2; - const ddx = tx - me.x; - const ddy = ty - me.y; + if (at !== undefined) { + // Beside the pillar on the door's axis, through the gap, and on + // into the next room (world coordinates: the rooms are one world). + const [rx, ry] = from.split('.').slice(1).map(Number); + const size = 2 * 20; + const tx = rx * size + (dx !== 0 ? dx * 24 : 1.7); + const ty = ry * size + (dy !== 0 ? dy * 24 : 1.7); + const ddx = tx - at.x; + const ddy = ty - at.y; const len = Math.max(Math.hypot(ddx, ddy), 1e-3); input = { mx: Math.round((ddx / len) * 127), my: Math.round((ddy / len) * 127), buttons: 0 }; } client.sendInput(input); await sleep(1000 / 30); } - return pit.room(); + return { room: pit.room(), trace }; +} + +/** Largest move (m) between consecutive samples of a `walkThroughDoor` trace. */ +export function largestStep(trace) { + let max = 0; + for (let i = 1; i < trace.length; i += 1) { + max = Math.max(max, Math.hypot(trace[i].x - trace[i - 1].x, trace[i].y - trace[i - 1].y)); + } + return max; } /** Hunt mobs (one command frame per 30 Hz tick) until `done()` or timeout. */ diff --git a/examples/game/client/tests/mobpit-headless.test.mjs b/examples/game/client/tests/mobpit-headless.test.mjs index 711ac2eb..7a343d1c 100644 --- a/examples/game/client/tests/mobpit-headless.test.mjs +++ b/examples/game/client/tests/mobpit-headless.test.mjs @@ -5,7 +5,8 @@ * * join the entrance room → profile read from the celld Crawler cell → kill * a mob → the fact settles ("saved") with server-decided XP → walk through - * a doorway into the next room → the left room goes dormant, then + * a doorway into the next room, continuously (one world: the own world + * position never jumps across the handoff) → the left room goes dormant, then * hibernated (zone status read model) → walk back → the room wakes from its * snapshot with the mobs killed there still dead → restart the services: * the profile is still there. @@ -17,9 +18,23 @@ import { isMob, isPit, pitFields } 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, walkThroughDoor } from './helpers/mobpit.mjs'; +import { hunt, largestStep, startPit, walkThroughDoor } from './helpers/mobpit.mjs'; const XP = new Set([10, 25, 60]); +/** One 30 Hz command frame of walking (5 m/s ≈ 0.167 m) plus float slack. */ +const STEP = 0.2; + +/** The own world position across a doorway: from room `a` into room `b`, never more than a step at once. */ +function assertContinuous(trace, a, b) { + const last = trace.findLast((s) => s.room === a); + const first = trace.find((s) => s.room === b && s.predicted); + assert.ok(last && first, `trace covers ${a} → ${b}`); + // Room-local positions would be a whole room (40 m) apart. + const across = Math.hypot(first.x - last.x, first.y - last.y); + console.error(`doorway ${a} → ${b}: ${across.toFixed(3)} m across, largest step ${largestStep(trace).toFixed(3)} m, ${trace.length} samples`); + assert.ok(across <= STEP, `${a} → ${b}: ${JSON.stringify(last)} → ${JSON.stringify(first)} (${across.toFixed(3)} m)`); + assert.ok(largestStep(trace) <= STEP, `largest step ${largestStep(trace).toFixed(3)} m`); +} test('rooms on celld: kills settle, doorways hand over, left rooms sleep and restore', { timeout: 600_000 }, async () => { const storage = memoryStorage(); @@ -58,9 +73,11 @@ test('rooms on celld: kills settle, doorways hand over, left rooms sleep and res await until(() => (pitStats()?.kills ?? 0) >= kills.length, 5_000, 'room kill count'); const roomKills = pitStats().kills; - // Through the east doorway: the room hands the player to pit.1.0. - const next = await walkThroughDoor(pit, [1, 0]); - assert.equal(next, 'pit.1.0'); + // Through the east doorway: the room hands the player to pit.1.0, + // at the same world point (no jump, no slide back across the room). + const east = await walkThroughDoor(pit, [1, 0]); + assert.equal(east.room, 'pit.1.0'); + assertContinuous(east.trace, 'pit.0.0', 'pit.1.0'); await until(() => pit.hello()?.roomX === 1, 10_000, 'hello of the next room'); // The left room empties: dormant, then hibernated. const status = async (zone) => (await pitApi.rooms()).rooms.find((r) => r.zone === zone)?.status; @@ -69,7 +86,9 @@ test('rooms on celld: kills settle, doorways hand over, left rooms sleep and res assert.equal(await status('pit.1.0'), 'active'); // Back west: the room wakes from its snapshot, the dead stay dead. - assert.equal(await walkThroughDoor(pit, [-1, 0]), 'pit.0.0'); + const west = await walkThroughDoor(pit, [-1, 0]); + assert.equal(west.room, 'pit.0.0'); + assertContinuous(west.trace, 'pit.1.0', 'pit.0.0'); await until(() => pit.hello()?.roomX === 0, 10_000, 'hello of the entrance again'); assert.equal(pit.hello().restored, true, 'restored from hibernation'); assert.equal(pit.hello().roomKills, roomKills); diff --git a/examples/game/server/rooms/src/zone.rs b/examples/game/server/rooms/src/zone.rs index ccf53a9f..c566ca4c 100644 --- a/examples/game/server/rooms/src/zone.rs +++ b/examples/game/server/rooms/src/zone.rs @@ -12,10 +12,13 @@ //! //! The room's mobs come from its seeded layout ([`pit_room`]: tiers and //! homes); the rest of a room is the Arena floor with a doorway in the -//! middle of each wall. Pushing through a doorway hands the player to the -//! neighbouring room ([`Ctx::handoff`], carrying where along the wall it -//! went through); the client follows the Handoff to the next room's zone, -//! wherever it runs. +//! middle of each wall. The rooms tile one continuous world (room `(x, y)` +//! at `(x, y) × ROOM_SIZE`): a doorway is a passage the predicted step walks +//! through, and a player [`HYSTERESIS`](game_sim::mobpit::HYSTERESIS) past +//! the wall line is handed to the neighbouring room ([`Ctx::handoff`], +//! carrying its [`MoveState`] in the neighbour's frame: the same world +//! point and velocity); the client follows the Handoff to the next room's +//! zone, wherever it runs, and draws it where it always was. //! //! Mob state machine (deterministic, zone RNG only for wander targets): //! `idle` (wander near home) → `chase` (living player within aggro radius or @@ -46,7 +49,8 @@ use distributed_game::{ }; use game_sim::arena::{self as movement, MoveState, PILLARS, PLAYER_RADIUS}; use game_sim::mobpit::{ - arrival, doorway, parse_room, pit_room, pit_world, room_zone, PitRoomContext, PitRoomFn, + self as rooms, admissible, crossing, into_neighbour, parse_room, pit_room, pit_world, + room_zone, PitRoomContext, PitRoomFn, }; use mobpit_crawler::{level_floor, KillReceipt, RecordKillInput}; use serde::{Deserialize, Serialize}; @@ -61,13 +65,13 @@ pub const PIT_ENTITY: EntityId = EntityId(900); #[derive(Debug, Clone, PartialEq, Replicated)] #[replicated(kind = "player", id = 1, priority = 2.0, of = Player)] pub struct PlayerView { - /// Position x. - #[field_type(movement::POS)] + /// Position x (room-local; past ±HALF in a doorway passage). + #[field_type(rooms::POS)] #[correct(ease = 150)] #[from(state.x)] pub x: f32, /// Position y. - #[field_type(movement::POS)] + #[field_type(rooms::POS)] #[correct(ease = 150)] #[from(state.y)] pub y: f32, @@ -454,8 +458,8 @@ struct Player { dead_until: Option, level: u32, regen: u8, - /// Stick of the last input (doorways need the push direction). - stick: (i8, i8), + /// Handed to a neighbouring room: no more steps here. + leaving: bool, } struct PendingKill { @@ -522,6 +526,15 @@ pub struct MobPit { tick: u64, } +fn spawn_state(player: PlayerId) -> MoveState { + let (x, y) = spawn_point(player); + MoveState { + x, + y, + ..MoveState::default() + } +} + fn spawn_point(player: PlayerId) -> (f32, f32) { const SPOTS: [(f32, f32); 6] = [ (-4.5, -4.5), @@ -565,22 +578,32 @@ fn dist(a: (f32, f32), b: (f32, f32)) -> f32 { ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() } -/// Carried through a doorway: the room offset it came from and where along -/// the wall it went through. -fn encode_carried(from: (i32, i32), along: f32) -> Vec { - let mut out = vec![from.0 as i8 as u8, from.1 as i8 as u8]; - out.extend_from_slice(&along.to_le_bytes()); +/// Carried through a doorway: the player's [`MoveState`] in the +/// neighbour's frame (x y vx vy as f32 LE, dash, cd). +fn encode_carried(s: &MoveState) -> Vec { + let mut out = Vec::with_capacity(18); + for v in [s.x, s.y, s.vx, s.vy] { + out.extend_from_slice(&v.to_le_bytes()); + } + out.extend_from_slice(&[s.dash, s.cd]); out } -fn decode_carried(bytes: &[u8]) -> Option<((i32, i32), f32)> { - let [dx, dy, a, b, c, d] = bytes else { +/// Inverse of [`encode_carried`]; None unless it is a place a player can be. +fn decode_carried(bytes: &[u8]) -> Option { + if bytes.len() != 18 { return None; + } + let f = |i: usize| f32::from_le_bytes(bytes[i * 4..i * 4 + 4].try_into().expect("4 bytes")); + let state = MoveState { + x: f(0), + y: f(1), + vx: f(2), + vy: f(3), + dash: bytes[16], + cd: bytes[17], }; - Some(( - (i32::from(*dx as i8), i32::from(*dy as i8)), - f32::from_le_bytes([*a, *b, *c, *d]), - )) + admissible(&state).then(|| rooms::canonicalize(state)) } impl MobPit { @@ -692,6 +715,9 @@ impl MobPit { } continue; } + if p.leaving { + continue; + } while p.credits > 0 { let Some(input) = ctx.take_inputs(*id, ConsumePolicy::Next).pop() else { break; @@ -700,7 +726,6 @@ impl MobPit { // The declared predicted step: what the client runs as WASM. p.state = pit_step(&p.state, &input, &(), movement::DT); p.credits -= 1; - p.stick = (input.mx, input.my); let (sx, sy) = (f32::from(input.mx), f32::from(input.my)); if sx * sx + sy * sy > 20.0 * 20.0 { p.face = sy.atan2(sx); @@ -710,6 +735,10 @@ impl MobPit { p.swing_anim = SWING_FRAMES; swings.push((*id, p.state.x, p.state.y, p.face)); } + // Past a wall line: the neighbour takes the rest. + if crossing(p.state.x, p.state.y).is_some() { + break; + } } p.regen = p.regen.wrapping_add(1); if p.regen % 15 == 0 && p.hp < PLAYER_HP { @@ -720,27 +749,30 @@ impl MobPit { swings } - /// Players pushing through a doorway move to the neighbouring room. + /// Players past a wall line (through a doorway passage) move to the + /// neighbouring room, at the same world point. fn walk_through_doorways(&mut self, ctx: &mut Ctx<'_, Self>) { let (rx, ry) = self.room.room; - let leaving: Vec<(PlayerId, (i32, i32), f32)> = self + let crossing_now: Vec<(PlayerId, (i32, i32))> = self .players .iter() - .filter(|(_, p)| p.dead_until.is_none()) - .filter_map(|(id, p)| { - let step = doorway(p.state.x, p.state.y, p.stick.0, p.stick.1)?; - let along = if step.0 != 0 { p.state.y } else { p.state.x }; - Some((*id, step, along)) - }) + .filter(|(_, p)| p.dead_until.is_none() && !p.leaving) + .filter_map(|(id, p)| Some((*id, crossing(p.state.x, p.state.y)?))) .collect(); - for (player, (dx, dy), along) in leaving { + for (player, (dx, dy)) in crossing_now { let target = room_zone((rx + dx, ry + dy)); - if parse_room(&target).is_none() { - continue; + 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() { + p.leaving = true; + } else { + // The edge of the world: back inside the wall line. + let line = movement::HALF - PLAYER_RADIUS; + p.state.x = p.state.x.clamp(-line, line); + p.state.y = p.state.y.clamp(-line, line); + p.state.vx = 0.0; + p.state.vy = 0.0; } - // The neighbour sees where it came from (the opposite side). - let carried = encode_carried((-dx, -dy), along); - let _ = ctx.handoff(player, &target, carried); } } @@ -943,7 +975,7 @@ impl MobPit { } /// Admit `player` at `(x, y)` and greet it. - fn admit(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId, (x, y): (f32, f32)) { + fn admit(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId, state: MoveState) { let (principal, name) = ctx .player(player) .map(|p| { @@ -960,11 +992,7 @@ impl MobPit { id: entity, principal, name, - state: MoveState { - x, - y, - ..MoveState::default() - }, + state, credits: MAX_CREDITS, hp: PLAYER_HP, face: 0.0, @@ -973,10 +1001,10 @@ impl MobPit { dead_until: None, level: 1, regen: 0, - stick: (0, 0), + leaving: false, }, ); - ctx.set_view(player, View::new(x, y, VIEW_RADIUS)); + ctx.set_view(player, View::new(state.x, state.y, VIEW_RADIUS)); ctx.send( player, &PitEvent::Hello { @@ -1061,7 +1089,7 @@ impl Zone for MobPit { } fn on_join(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId) { - self.admit(ctx, player, spawn_point(player)); + self.admit(ctx, player, spawn_state(player)); } fn on_handoff_join( @@ -1071,9 +1099,7 @@ impl Zone for MobPit { _from_zone: &str, carried: Vec, ) { - let at = decode_carried(&carried) - .map(|(from, along)| arrival(from, along)) - .unwrap_or_else(|| spawn_point(player)); + let at = decode_carried(&carried).unwrap_or_else(|| spawn_state(player)); // Still here inside its reconnect grace (a doorway bounce): replace. self.players.remove(&player); self.admit(ctx, player, at); diff --git a/examples/game/server/rooms/tests/rooms.rs b/examples/game/server/rooms/tests/rooms.rs index 474fdf45..9ade8a84 100644 --- a/examples/game/server/rooms/tests/rooms.rs +++ b/examples/game/server/rooms/tests/rooms.rs @@ -1,17 +1,23 @@ //! The room zone on the reference host (`ZoneHost`, manual clock): what a //! celld room object runs, without celld. -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::Duration; use distributed_game::protocol::ServerMessage; use distributed_game::{ - ClientId, FactQueue, GameEvent, HostConfig, ManualClock, Outbound, Role, TicketClaims, Viewer, - ZoneHost, + ClientId, FactQueue, GameEvent, HandoffRequest, HostConfig, ManualClock, Outbound, Role, + TicketClaims, Transfer, Viewer, ZoneHost, }; use mobpit_rooms::{encode_bot_input, MobPit, PitEvent, PitInput, RoomConfig, DEFAULT_SEED}; +type Issued = Arc>>; + fn host(zone: &str, clock: &ManualClock) -> ZoneHost { + host_issuing(zone, clock, Issued::default()) +} + +fn host_issuing(zone: &str, clock: &ManualClock, issued: Issued) -> ZoneHost { let (facts, _script) = FactQueue::scripted(64); ZoneHost::::new( zone, @@ -23,7 +29,10 @@ fn host(zone: &str, clock: &ManualClock) -> ZoneHost { RoomConfig::for_zone(zone, DEFAULT_SEED).expect("room"), ) .with_facts(facts) - .with_transfer_issuer(|request| Some(format!("transfer:{}", request.target_zone))) + .with_transfer_issuer(move |request| { + *issued.lock().expect("issued") = Some(request.clone()); + Some(format!("transfer:{}", request.target_zone)) + }) } fn join(host: &mut ZoneHost, who: &str) -> (ClientId, distributed_game::PlayerId) { @@ -66,7 +75,8 @@ fn hellos(out: &[Outbound]) -> Vec { #[test] fn a_room_greets_with_its_coordinates_and_hands_off_through_doorways() { let clock = ManualClock::new(); - let mut room = host("pit.0.0", &clock); + let issued = Issued::default(); + let mut room = host_issuing("pit.0.0", &clock, issued.clone()); let (client, player) = join(&mut room, "ana"); let out = tick(&mut room, &clock); let hello = hellos(&out); @@ -82,8 +92,10 @@ fn a_room_greets_with_its_coordinates_and_hands_off_through_doorways() { // Walk to the east doorway (beside the pillar on its axis) and push // through it, no attack, until the room hands the player over. let mut target = None; + let mut last = (0.0, 0.0); for seq in 1..600u32 { let me = room.zone().player_position(player).expect("in the room"); + last = me; let (dx, dy) = (21.0 - me.0, 2.0 - me.1); let len = (dx * dx + dy * dy).sqrt().max(1e-3); let east = PitInput { @@ -113,6 +125,41 @@ fn a_room_greets_with_its_coordinates_and_hands_off_through_doorways() { assert_eq!(target, "pit.1.0"); assert_eq!(ticket, "transfer:pit.1.0"); assert!(room.zone().player_position(player).is_none(), "left"); + // Through the passage, past the wall line (x = 20) by the hysteresis. + assert!(last.0 > 19.5 && last.1.abs() < 2.5, "{last:?}"); + + // The neighbour admits the player at the same world point: room 1's + // local x is 40 m less (one step further at most). + let request = issued + .lock() + .expect("issued") + .take() + .expect("handoff request"); + let mut next = host("pit.1.0", &clock); + let claims = TicketClaims::new("ana", "pit.1.0", u64::MAX, "t2", Role::Player).with_name("ana"); + let transfer = Transfer { + claims, + source_zone: "pit.0.0".into(), + issuer: "test".into(), + generation: 0, + carried: request.carried, + departed_at_ms: 0, + }; + let joined = next.join_transfer(&transfer, 0, "").expect("transfer join"); + let Viewer::Player(arrived) = joined.viewer else { + panic!("player") + }; + let at = next.zone().player_position(arrived).expect("arrived"); + let world_before = (last.0, last.1); + let world_after = (40.0 + at.0, at.1); + let moved = ((world_after.0 - world_before.0).powi(2) + + (world_after.1 - world_before.1).powi(2)) + .sqrt(); + assert!( + moved < 0.5, + "same world point: {world_before:?} → {world_after:?}" + ); + assert!(at.0 < -19.0, "just inside the west wall of pit.1.0: {at:?}"); } #[test] diff --git a/examples/game/server/sim/src/arena.rs b/examples/game/server/sim/src/arena.rs index 5c11dd20..aafd269a 100644 --- a/examples/game/server/sim/src/arena.rs +++ b/examples/game/server/sim/src/arena.rs @@ -140,6 +140,13 @@ pub fn arena_step(state: &MoveState, input: &MoveInput, _context: &(), _dt: f32) /// 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`). +pub fn advance(s: MoveState, input: MoveInput) -> MoveState { let mut s = s; // Stick direction, length clamped to 1. let mut dx = f32::from(input.mx) / 127.0; @@ -213,7 +220,12 @@ pub fn step(s: MoveState, input: MoveInput) -> MoveState { } } } + s +} +/// The Arena's closed walls. +fn clamp_walls(s: MoveState) -> MoveState { + let mut s = s; let lim = HALF - PLAYER_RADIUS; if s.x < -lim { s.x = -lim; @@ -229,10 +241,11 @@ pub fn step(s: MoveState, input: MoveInput) -> MoveState { s.y = lim; s.vy = s.vy.min(0.0); } - canonicalize(s) + s } -fn snap(ty: FieldType, v: f32) -> f32 { +/// Snap `v` to the precision of wire type `ty`. +pub fn snap(ty: FieldType, v: f32) -> f32 { match ty { FieldType::QuantF32 { min, max, bits } => FieldType::dequantize_linear( FieldType::quantize_linear(v, min, max, bits), diff --git a/examples/game/server/sim/src/mobpit.rs b/examples/game/server/sim/src/mobpit.rs index 9d4b3033..a1fa4596 100644 --- a/examples/game/server/sim/src/mobpit.rs +++ b/examples/game/server/sim/src/mobpit.rs @@ -3,14 +3,23 @@ //! //! **Rooms.** Mob Pit is a near-infinite grid of rooms, room `(x, y)` being //! zone `pit..` (canonical integers, |x|, |y| ≤ [`MAX_ROOM`]). Every -//! room is the Arena floor (same pillars, so the predicted step is the -//! Arena's) with a doorway in the middle of each wall leading to the -//! neighbour. What lives in a room — its biome, danger level and mobs — is +//! room is the Arena floor (same pillars) with a doorway in the middle of +//! each wall leading to the neighbour. +//! +//! **One continuous world.** Room `(x, y)` sits at world `(x, y) ×` +//! [`ROOM_SIZE`]; neighbours share a wall. Positions on the wire are +//! room-local (centred), so world = room × [`ROOM_SIZE`] + local. A doorway +//! is a passage: [`pit_step`] lets the player walk through the gap past the +//! wall line (up to [`PASSAGE`] m); once it is [`HYSTERESIS`] m past the +//! line ([`crossing`]) the room hands it to the neighbour, which admits it +//! at the same world point with the same velocity ([`into_neighbour`]), so +//! nothing jumps and the client's prediction carries on (the Endless Grid's +//! model). What lives in a room — its biome, danger level and mobs — is //! a pure function of `(seed, x, y)` ([`pit_room`], `#[game_pure]`) built on //! the Endless Grid's seeded hashing and biomes ([`crate::grid`]); the page //! runs the same code compiled to WebAssembly (minimap colours, floor). -use distributed_game::{game_pure, predict, GameInput, GameRecord}; +use distributed_game::{game_pure, predict, FieldType, GameInput, GameRecord}; use crate::arena::{self, MoveInput, MoveState}; use crate::grid; @@ -47,10 +56,76 @@ impl PitInput { } /// The own capsule's predicted step: the Arena's movement of -/// [`PitInput::movement`]. +/// [`PitInput::movement`] inside walls with doorway passages. #[predict(PitStep, kind = "player")] pub fn pit_step(state: &MoveState, input: &PitInput, _context: &(), _dt: f32) -> MoveState { - arena::step(*state, input.movement()) + step(*state, *input) +} + +/// Room edge length (m): rooms tile the world, sharing walls. +pub const ROOM_SIZE: f32 = 2.0 * arena::HALF; +/// How far past the wall line a doorway passage reaches (m). +pub const PASSAGE: f32 = 2.0; +/// Handoff hysteresis (m): a player belongs to the neighbour once it is +/// this far past the wall line, and so arrives this far inside it. +pub const HYSTERESIS: f32 = 0.5; +/// Tolerance of the wall tests (m), far above the wire precision. +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)) +} + +/// Snap to the replicated precision of a Mob Pit player. +pub fn canonicalize(s: MoveState) -> MoveState { + MoveState { + 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), + ..s + } +} + +/// Clamp a coordinate to `±lim`, stopping the velocity into the wall. +fn clamp_axis(p: f32, v: f32, lim: f32) -> (f32, f32) { + if p > lim { + (lim, v.min(0.0)) + } else if p < -lim { + (-lim, v.max(0.0)) + } else { + (p, v) + } +} + +/// Keep `s` (one frame after `prev`) inside the room square or a doorway +/// passage. A player already in a passage stays between its jambs; one in +/// the room may enter a passage only through the gap. +fn walls(prev: MoveState, s: MoveState) -> MoveState { + let lim = arena::HALF - arena::PLAYER_RADIUS; + // Snapped to the wire, a player held at the wall may sit a hair past it. + let inside = lim + SLACK; + let gap = DOOR_HALF - arena::PLAYER_RADIUS; + let far = arena::HALF + PASSAGE; + let mut s = s; + let (lim_x, lim_y) = if prev.x.abs() > inside { + (far, gap) + } else if prev.y.abs() > inside { + (gap, far) + } else { + ( + if s.y.abs() <= gap { far } else { lim }, + if s.x.abs() <= gap { far } else { lim }, + ) + }; + (s.x, s.vx) = clamp_axis(s.x, s.vx, lim_x); + (s.y, s.vy) = clamp_axis(s.y, s.vy, lim_y); + s } /// Largest |room coordinate|. @@ -172,6 +247,12 @@ pub struct PitWorld { pub half: f32, /// Doorway half width (m). pub door_half: f32, + /// Room edge length (m): room `(x, y)` is centred at world `(x, y) × room_size`. + pub room_size: f32, + /// Doorway passage depth past the wall line (m). + pub passage: f32, + /// Handoff hysteresis past the wall line (m). + pub hysteresis: f32, /// Largest |room coordinate|. pub max_room: i32, /// Biome names by index. @@ -184,6 +265,9 @@ pub fn pit_world() -> PitWorld { default_seed: DEFAULT_SEED, half: arena::HALF, door_half: DOOR_HALF, + room_size: ROOM_SIZE, + passage: PASSAGE, + hysteresis: HYSTERESIS, max_room: MAX_ROOM, biomes: grid::BIOMES.map(String::from).to_vec(), } @@ -205,42 +289,45 @@ pub fn parse_room(zone_id: &str) -> Option<(i32, i32)> { Some((parse(x)?, parse(y)?)) } -/// The doorway a player at `(x, y)` pushing with stick `(mx, my)` walks -/// through, as a room offset: against the wall, inside the doorway span, -/// pushing outward. -pub fn doorway(x: f32, y: f32, mx: i8, my: i8) -> Option<(i32, i32)> { - let edge = arena::HALF - arena::PLAYER_RADIUS - 0.05; - if y.abs() < DOOR_HALF { - if x >= edge && mx > 30 { - return Some((1, 0)); - } - if x <= -edge && mx < -30 { - return Some((-1, 0)); - } - } - if x.abs() < DOOR_HALF { - if y >= edge && my > 30 { - return Some((0, 1)); - } - if y <= -edge && my < -30 { - return Some((0, -1)); - } +/// The neighbour a player at room-local `(x, y)` belongs to, as a room +/// offset: [`HYSTERESIS`] past a wall line (only reachable through a +/// doorway passage). +pub fn crossing(x: f32, y: f32) -> Option<(i32, i32)> { + let line = arena::HALF + HYSTERESIS; + if x > line { + Some((1, 0)) + } else if x < -line { + Some((-1, 0)) + } else if y > line { + Some((0, 1)) + } else if y < -line { + Some((0, -1)) + } else { + None } - None } -/// Where a player arrives through the doorway it came in by (`from` is the -/// offset of the room it left, e.g. `(-1, 0)` = came from the west), -/// keeping its position along the wall. -pub fn arrival(from: (i32, i32), along: f32) -> (f32, f32) { - let inside = arena::HALF - 2.0; - let along = along.clamp(-(DOOR_HALF - 0.6), DOOR_HALF - 0.6); - match from { - (-1, 0) => (-inside, along), - (1, 0) => (inside, along), - (0, -1) => (along, -inside), - _ => (along, inside), - } +/// 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 { + x: s.x - step.0 as f32 * ROOM_SIZE, + y: s.y - step.1 as f32 * ROOM_SIZE, + ..s + }) +} + +/// Whether `s` is a position a player can be in (the room or a passage). +pub fn admissible(s: &MoveState) -> bool { + let lim = arena::HALF - arena::PLAYER_RADIUS + SLACK; + let gap = DOOR_HALF - arena::PLAYER_RADIUS + SLACK; + let far = arena::HALF + PASSAGE + SLACK; + let (ax, ay) = (s.x.abs(), s.y.abs()); + s.x.is_finite() + && s.y.is_finite() + && s.vx.is_finite() + && s.vy.is_finite() + && ((ax <= lim && ay <= lim) || (ay <= gap && ax <= far) || (ax <= gap && ay <= far)) } #[cfg(test)] @@ -272,12 +359,72 @@ mod tests { assert_eq!(parse_room("pit.01.2"), None); assert_eq!(parse_room("pit-1"), None); assert_eq!(parse_room("pit.2000000.0"), None); - let edge = arena::HALF - arena::PLAYER_RADIUS; - assert_eq!(doorway(edge, 0.5, 127, 0), Some((1, 0))); - assert_eq!(doorway(edge, 0.5, -127, 0), None, "pushing back in"); - assert_eq!(doorway(edge, 5.0, 127, 0), None, "wall, not doorway"); - assert_eq!(doorway(0.0, -edge, 0, -127), Some((0, -1))); - assert_eq!(arrival((-1, 0), 9.0).0, -(arena::HALF - 2.0)); - assert!(arrival((-1, 0), 9.0).1 < DOOR_HALF); + } + + fn walk(mut s: MoveState, mx: i8, my: i8, frames: usize) -> MoveState { + for _ in 0..frames { + s = step(s, PitInput { mx, my, buttons: 0 }); + } + s + } + + #[test] + fn doorways_are_passages_and_walls_hold() { + 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 { + x, + y, + ..MoveState::default() + }; + let s = walk(at(17.0, 0.5), 127, 0, 60); + assert!((s.x - (arena::HALF + PASSAGE)).abs() < 1e-3, "{s:?}"); + assert_eq!(crossing(s.x, s.y), Some((1, 0))); + // Beside the doorway the wall holds. + let s = walk(at(17.0, 6.0), 127, 0, 60); + assert!((s.x - lim).abs() < 1e-3, "{s:?}"); + assert_eq!(crossing(s.x, s.y), None); + // In the passage the jambs hold the player in the gap. + let s = walk(at(20.4, 0.0), 0, 127, 60); + assert!(s.y <= DOOR_HALF - arena::PLAYER_RADIUS + 1e-3, "{s:?}"); + assert!(admissible(&s)); + // Corners never open. + let s = walk(at(18.0, 18.0), 127, 127, 90); + assert!(s.x <= lim + 1e-3 && s.y <= lim + 1e-3, "{s:?}"); + } + + #[test] + fn crossing_keeps_the_world_point() { + let s = canonicalize(MoveState { + x: arena::HALF + HYSTERESIS + 0.1, + y: 1.25, + vx: 5.0, + ..MoveState::default() + }); + let step = crossing(s.x, s.y).expect("past the line"); + let n = into_neighbour(step, s); + // World x: room 0 + s.x == room 1 + n.x. + assert!((n.x + ROOM_SIZE - s.x).abs() < 1e-3, "{n:?}"); + assert_eq!((n.y, n.vx), (s.y, s.vx)); + 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 }); + assert!((w.x - 19.0).abs() < 1e-3, "{w:?}"); + let south = into_neighbour( + (0, -1), + MoveState { + x: 0.0, + y: -21.0, + ..s + }, + ); + assert!((south.y - 19.0).abs() < 1e-3, "{south:?}"); + assert!(!admissible(&MoveState { + x: 21.0, + y: 5.0, + ..s + })); } } diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts b/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts index e01248b1..2b523df3 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/constants.ts @@ -21,6 +21,9 @@ export const PIT_WORLD: PitWorld = { defaultSeed: 186474221, half: 20, doorHalf: 2.5, + roomSize: 40, + passage: 2, + hysteresis: 0.5, maxRoom: 1000000, biomes: ['highlands', 'steppe', 'forest', 'meadow'] }; diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/events.ts b/examples/game/sveltekit/src/lib/generated/mobpit/events.ts index a3f5878b..b9815227 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/events.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/events.ts @@ -55,6 +55,9 @@ export interface PitWorld { readonly defaultSeed: number; readonly half: number; readonly doorHalf: number; + readonly roomSize: number; + readonly passage: number; + readonly hysteresis: number; readonly maxRoom: number; readonly biomes: readonly string[]; } diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/schema.ts b/examples/game/sveltekit/src/lib/generated/mobpit/schema.ts index 8019b0b3..cf619af1 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/schema.ts +++ b/examples/game/sveltekit/src/lib/generated/mobpit/schema.ts @@ -15,9 +15,9 @@ export type KindName = keyof typeof KIND; /** `player` (kind 1, priority 2). */ export interface PlayerFields { - /** `quant [-20, 19.999847] / 18 bits` */ + /** `quant [-22, 21.999832] / 18 bits` */ readonly x: number; - /** `quant [-20, 19.999847] / 18 bits` */ + /** `quant [-22, 21.999832] / 18 bits` */ readonly y: number; /** `quant [-16, 15.9921875] / 12 bits` */ readonly vx: number; @@ -164,8 +164,8 @@ export function pitFields(entity: { readonly fields: EntityFields }): PitFields /** 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 }, + x: { type: 'quant', min: -22, max: 21.999832153320313, bits: 18 }, + y: { type: 'quant', min: -22, max: 21.999832153320313, 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' }, diff --git a/examples/game/sveltekit/src/lib/generated/mobpit/surface.json b/examples/game/sveltekit/src/lib/generated/mobpit/surface.json index 6c0a39c7..4299b39e 100644 --- a/examples/game/sveltekit/src/lib/generated/mobpit/surface.json +++ b/examples/game/sveltekit/src/lib/generated/mobpit/surface.json @@ -53,7 +53,10 @@ "default_seed": 186474221, "door_half": 2.5, "half": 20.0, - "max_room": 1000000 + "hysteresis": 0.5, + "max_room": 1000000, + "passage": 2.0, + "room_size": 40.0 } } ], @@ -330,8 +333,8 @@ "priority": 1.0, "type": { "bits": 18, - "max": 19.999847, - "min": -20.0, + "max": 21.999832, + "min": -22.0, "type": "quant" } }, @@ -344,8 +347,8 @@ "priority": 1.0, "type": { "bits": 18, - "max": 19.999847, - "min": -20.0, + "max": 21.999832, + "min": -22.0, "type": "quant" } }, @@ -854,6 +857,24 @@ "type": "f32" } }, + { + "name": "room_size", + "type": { + "type": "f32" + } + }, + { + "name": "passage", + "type": { + "type": "f32" + } + }, + { + "name": "hysteresis", + "type": { + "type": "f32" + } + }, { "name": "max_room", "type": { diff --git a/examples/game/sveltekit/src/lib/pit/loop.ts b/examples/game/sveltekit/src/lib/pit/loop.ts index f6c75154..ae93a998 100644 --- a/examples/game/sveltekit/src/lib/pit/loop.ts +++ b/examples/game/sveltekit/src/lib/pit/loop.ts @@ -3,11 +3,18 @@ * `requestAnimationFrame`, outside Svelte. It reads `client.render()` every * frame and sends one input per server tick; the Svelte page only starts it * and reads the slower facts (status, RTT, events) through `useGame`. + * + * One continuous world: room `(x, y)` sits at world `(x, y) × ROOM`, the + * view follows the own player's world position, and the rooms around are + * drawn from their seeded layout, so walking through a doorway just + * continues into the next room (the room hands the player over at the same + * world point). */ -import { InputPacer, type GameClient } from '@hops-ops/distributed/game'; +import { InputPacer, type EntityFields, type GameClient, type RenderedEntity } from '@hops-ops/distributed/game'; import { BUTTON, + createMobpitPures, PIT_RULES, PIT_WORLD, isMob, @@ -24,6 +31,39 @@ const TIER_COLOR: Record = { 1: '#9ad16b', 2: '#b48cf2', 3: '#ff const TIER_RADIUS = Object.fromEntries(PIT_RULES.tiers.map((t) => [t.tier, t])); const PLAYER_RADIUS = 0.45; const DOOR_HALF = PIT_WORLD.doorHalf; +const HALF = PIT_WORLD.half; +/** Room edge (m): room `(x, y)` is centred at world `(x, y) × ROOM`, one continuous world. */ +const ROOM = PIT_WORLD.roomSize; +/** Metres across the canvas: your room and a strip of each neighbour. */ +const VIEW = 2 * HALF + 12; +/** Floor tint per biome (`PIT_WORLD.biomes`). */ +const BIOME_COLOR = ['#8a8f99', '#c9a86a', '#3f7d4e', '#7fbf6a']; + +/** `pit..` → `[x, y]`. */ +function parseRoom(zone: string | undefined): [number, number] | undefined { + const m = zone === undefined ? null : /^pit\.(-?\d+)\.(-?\d+)$/.exec(zone); + return m === null ? undefined : [Number(m[1]), Number(m[2])]; +} + +/** + * The own player's predicted fields in the next room's frame (the same world + * point): the SDK keeps predicting through a doorway handoff (`handoffFrame`). + */ +export function pitHandoffFrame(fields: EntityFields, from: string, to: string): EntityFields | undefined { + const a = parseRoom(from); + const b = parseRoom(to); + const { x, y } = fields as { x?: unknown; y?: unknown }; + if (a === undefined || b === undefined || typeof x !== 'number' || typeof y !== 'number') return undefined; + return { ...fields, x: x + (a[0] - b[0]) * ROOM, y: y + (a[1] - b[1]) * ROOM }; +} + +/** World position (m) of an entity: its room's place plus its room-local x / y. */ +function worldOf(entity: RenderedEntity): { x: number; y: number } | undefined { + const room = parseRoom(entity.zoneId); + const { x, y } = entity.fields as { x?: unknown; y?: unknown }; + if (room === undefined || typeof x !== 'number' || typeof y !== 'number') return undefined; + return { x: room[0] * ROOM + x, y: room[1] * ROOM + y }; +} /** Swing cone, from the server's rules (reach to a mob's edge, plus the body). */ const SWING_REACH = PIT_RULES.attackRange + PLAYER_RADIUS; const SWING_HALF = PIT_RULES.attackHalfAngle; @@ -79,16 +119,41 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) }; }; + // The rooms around are drawn from their seeded layout (`pit_room`, the + // Rust generator as WASM): biome floor, walls, doorways, pillars. + const pures = createMobpitPures(); + /** Pillars are the same in every room: learned from the replicated ones. */ + let pillars: { x: number; y: number; r: number }[] = []; + let lastMe: { x: number; y: number } | undefined; + let lastFrame = 0; + let frame = requestAnimationFrame(function draw(now) { pacer.update(now, () => client.sendInput(steer())); - const half = options.hello()?.half ?? 12; - // Only the room the player is in (during a doorway handoff the - // client briefly holds both). + const hello = options.hello(); const room = client.authority?.zoneId; if (room !== undefined) canvas.dataset.room = room; + const here = parseRoom(room); + const rendered = client.render(); + // Yours: predicted, or the SDK's hold of it during a doorway handoff. + const mine = (e: RenderedEntity) => isPlayer(e) && (e.predicted || (e.id === hello?.entity && e.zoneId === room)); + const meEntity = rendered.find((e) => mine(e) && e.predicted) ?? rendered.find(mine); + const me = meEntity === undefined ? undefined : worldOf(meEntity); + // The view follows you through one continuous world. + const centre = me ?? (here === undefined ? { x: 0, y: 0 } : { x: here[0] * ROOM, y: here[1] * ROOM }); + if (me !== undefined) { + canvas.dataset.world = `${me.x.toFixed(3)},${me.y.toFixed(3)}`; + // Largest frame-to-frame move of the view (m), frames ≤ 50 ms + // apart; the headless test resets it to 0 before a doorway. + if (lastMe !== undefined && now - lastFrame <= 50) { + const jump = Math.hypot(me.x - lastMe.x, me.y - lastMe.y); + if (jump > Number(canvas.dataset.jump ?? 0)) canvas.dataset.jump = jump.toFixed(3); + } + lastMe = me; + } + lastFrame = now; const { width, height } = canvas; - const scale = Math.min(width, height) / (2 * half); - const at = (x: number, y: number): [number, number] => [width / 2 + x * scale, height / 2 - y * scale]; + const scale = Math.min(width, height) / VIEW; + const at = (x: number, y: number): [number, number] => [width / 2 + (x - centre.x) * scale, height / 2 - (y - centre.y) * scale]; const circle = (x: number, y: number, r: number, fill: string, stroke?: string) => { const [cx, cy] = at(x, y); ctx.beginPath(); @@ -100,48 +165,74 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) ctx.stroke(); } }; - ctx.fillStyle = '#120f14'; - ctx.fillRect(0, 0, width, height); - ctx.strokeStyle = '#2b2533'; - ctx.strokeRect(...at(-half, half), 2 * half * scale, 2 * half * scale); - // Doorways: the middle of each wall leads to the neighbouring room. - ctx.strokeStyle = '#4dd0e1'; - ctx.lineWidth = 4; - for (const [x0, y0, x1, y1] of [ - [half, -DOOR_HALF, half, DOOR_HALF], - [-half, -DOOR_HALF, -half, DOOR_HALF], - [-DOOR_HALF, half, DOOR_HALF, half], - [-DOOR_HALF, -half, DOOR_HALF, -half] - ]) { + const line = (x0: number, y0: number, x1: number, y1: number) => { ctx.beginPath(); - ctx.moveTo(...at(x0!, y0!)); - ctx.lineTo(...at(x1!, y1!)); + ctx.moveTo(...at(x0, y0)); + ctx.lineTo(...at(x1, y1)); ctx.stroke(); + }; + ctx.fillStyle = '#0b090d'; + ctx.fillRect(0, 0, width, height); + const seed = hello?.seed ?? PIT_WORLD.defaultSeed; + const [cx, cy] = [Math.round(centre.x / ROOM), Math.round(centre.y / ROOM)]; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + const [rx, ry] = [cx + dx, cy + dy]; + const [ox, oy] = [rx * ROOM, ry * ROOM]; + const biome = pures.loaded() ? pures.pitRoom({ seed, x: rx, y: ry }).biome : undefined; + ctx.fillStyle = biome === undefined ? '#120f14' : `${BIOME_COLOR[biome] ?? '#333333'}30`; + ctx.fillRect(...at(ox - HALF, oy + HALF), 2 * HALF * scale, 2 * HALF * scale); + for (const p of pillars) circle(ox + p.x, oy + p.y, p.r, '#3a3443'); + // Walls with a doorway gap in the middle of each. + ctx.strokeStyle = '#4b5668'; + ctx.lineWidth = 3; + for (const s of [-1, 1]) { + line(ox - HALF, oy + s * HALF, ox - DOOR_HALF, oy + s * HALF); + line(ox + DOOR_HALF, oy + s * HALF, ox + HALF, oy + s * HALF); + line(ox + s * HALF, oy - HALF, ox + s * HALF, oy - DOOR_HALF); + line(ox + s * HALF, oy + DOOR_HALF, ox + s * HALF, oy + HALF); + } + ctx.strokeStyle = 'rgba(77,208,225,0.5)'; + ctx.lineWidth = 2; + for (const s of [-1, 1]) { + line(ox - DOOR_HALF, oy + s * HALF, ox + DOOR_HALF, oy + s * HALF); + line(ox + s * HALF, oy - DOOR_HALF, ox + s * HALF, oy + DOOR_HALF); + } + if (rx === here?.[0] && ry === here?.[1]) { + ctx.strokeStyle = 'rgba(255,255,255,0.25)'; + ctx.lineWidth = 1; + ctx.strokeRect(...at(ox - HALF, oy + HALF), 2 * HALF * scale, 2 * HALF * scale); + } + } } ctx.lineWidth = 1; - for (const entity of client.render()) { - if (room !== undefined && entity.zoneId !== room) continue; + for (const entity of rendered) { + const w = worldOf(entity); + if (w === undefined) continue; if (isPillar(entity)) { const p = pillarFields(entity); - circle(p.x, p.y, p.r, '#3a3443'); + if (!pillars.some((q) => q.x === p.x && q.y === p.y)) pillars = [...pillars, { x: p.x, y: p.y, r: p.r }]; } else if (isMob(entity)) { + if (entity.stale) continue; const m = mobFields(entity); const look = TIER_RADIUS[m.tier]; - if (m.tele > 0 && look !== undefined) circle(m.x, m.y, look.tele, 'rgba(255,80,80,0.12)', '#ff5050'); - circle(m.x, m.y, look?.radius ?? 0.5, TIER_COLOR[m.tier] ?? '#9ad16b'); + if (m.tele > 0 && look !== undefined) circle(w.x, w.y, look.tele, 'rgba(255,80,80,0.12)', '#ff5050'); + circle(w.x, w.y, look?.radius ?? 0.5, TIER_COLOR[m.tier] ?? '#9ad16b'); } else if (isPlayer(entity)) { + if (entity.stale) continue; const p = playerFields(entity); - const mine = entity.id === options.hello()?.entity; - // Own position for the headless test (a DOM attribute, not Svelte state). - if (mine) canvas.dataset.me = `${p.x.toFixed(2)},${p.y.toFixed(2)}`; + const isMe = entity === meEntity; + if (!isMe && mine(entity)) continue; + // Own room-local position for the headless test (a DOM attribute, not Svelte state). + if (isMe) canvas.dataset.me = `${p.x.toFixed(2)},${p.y.toFixed(2)}`; if (p.swing > 0 && !p.dead) { // The swing cone (blue), fading as `swing` counts down. const fade = p.swing / PIT_RULES.swingFrames; ctx.beginPath(); - ctx.moveTo(...at(p.x, p.y)); + ctx.moveTo(...at(w.x, w.y)); for (let i = 0; i <= 16; i += 1) { const a = p.face - SWING_HALF + (2 * SWING_HALF * i) / 16; - ctx.lineTo(...at(p.x + Math.cos(a) * SWING_REACH, p.y + Math.sin(a) * SWING_REACH)); + ctx.lineTo(...at(w.x + Math.cos(a) * SWING_REACH, w.y + Math.sin(a) * SWING_REACH)); } ctx.closePath(); ctx.fillStyle = `rgba(77,163,255,${0.12 + 0.25 * fade})`; @@ -149,19 +240,19 @@ export function startPitLoop(canvas: HTMLCanvasElement, options: PitLoopOptions) ctx.strokeStyle = '#4da3ff'; ctx.stroke(); // Frames that drew an own swing, for the headless test. - if (mine) canvas.dataset.swings = String(Number(canvas.dataset.swings ?? 0) + 1); + if (isMe) canvas.dataset.swings = String(Number(canvas.dataset.swings ?? 0) + 1); } - circle(p.x, p.y, PLAYER_RADIUS, p.dead ? '#555' : mine ? '#ffd166' : '#6ec3ff'); - const [nx, ny] = at(p.x + Math.cos(p.face) * 0.6, p.y + Math.sin(p.face) * 0.6); - const [cx, cy] = at(p.x, p.y); + circle(w.x, w.y, PLAYER_RADIUS, p.dead ? '#555' : isMe ? '#ffd166' : '#6ec3ff'); + const [nx, ny] = at(w.x + Math.cos(p.face) * 0.6, w.y + Math.sin(p.face) * 0.6); + const [px, py] = at(w.x, w.y); ctx.strokeStyle = '#fff'; ctx.beginPath(); - ctx.moveTo(cx, cy); + ctx.moveTo(px, py); ctx.lineTo(nx, ny); ctx.stroke(); ctx.fillStyle = '#ddd'; ctx.font = '11px system-ui'; - ctx.fillText(p.name, cx - 16, cy - 0.7 * scale); + ctx.fillText(p.name, px - 16, py - 0.7 * scale); } } frame = requestAnimationFrame(draw); diff --git a/examples/game/sveltekit/src/routes/pit/+page.svelte b/examples/game/sveltekit/src/routes/pit/+page.svelte index 031f0025..5186741b 100644 --- a/examples/game/sveltekit/src/routes/pit/+page.svelte +++ b/examples/game/sveltekit/src/routes/pit/+page.svelte @@ -4,7 +4,7 @@ import Leaderboard from '$lib/pit/Leaderboard.svelte'; import MyStats from '$lib/pit/MyStats.svelte'; import Rooms from '$lib/pit/Rooms.svelte'; - import { startPitLoop } from '$lib/pit/loop'; + import { pitHandoffFrame, startPitLoop } from '$lib/pit/loop'; let { data } = $props(); @@ -19,7 +19,9 @@ url: (zone) => `${data.roomsUrls[entry++ % data.roomsUrls.length]}/rooms/${encodeURIComponent(zone)}`, ticket: gameTicketSource(), pingIntervalMs: 500, - inputDtMs: 1000 / 30 + inputDtMs: 1000 / 30, + // One continuous world: keep predicting through doorways. + prediction: { handoffFrame: pitHandoffFrame } }), { zone: () => data.zone, events: EVENTS } ); diff --git a/examples/game/sveltekit/tests/pit.test.mjs b/examples/game/sveltekit/tests/pit.test.mjs index ca7b6dc5..fe47bf54 100644 --- a/examples/game/sveltekit/tests/pit.test.mjs +++ b/examples/game/sveltekit/tests/pit.test.mjs @@ -85,11 +85,39 @@ function start(command, args, options, ready) { }; } -/** Walk `page`'s player through the doorway on `side`; resolves with the new room. */ +/** Largest frame-to-frame move (m) of the own world position / view, ≈ one tick of walking. */ +const MAX_JUMP = 0.25; + +/** + * Walk `page`'s player through the doorway on `side`; resolves with the new + * room once the handoff completed. Asserts the world stays continuous: the + * own world position (= the view centre) never moves more than a tick's + * walk between frames, across the doorway too (room-local positions would + * jump by a whole room). + */ async function walkThrough(page, side) { - const goal = { east: [21, 2], west: [-21, 2], north: [2, 21], south: [2, -21] }[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 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; +} + +async function walkInto(page, goal, room, from) { 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)); From dcda004322ba41a6edfcb4483d8d34d1d6840080 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 23:03:59 -0500 Subject: [PATCH 11/87] fix(js): end a game connection attempt on a refused Hello A zone that refuses a Hello (expired reconnect token, bad ticket) sends Error and should close the socket. celld room objects send the Error but the close never reaches the client, so after a celld restart a connected page tried its void token and then hung in 'reconnecting' forever. The client now ends the attempt itself on a pre-Welcome Error: a resume falls back to a fresh ticket, a refused first connect rejects. Refs [[tasks/gr-p45-same-player-after-restarts]] --- js/src/game/connection.ts | 8 +++++++ js/tests/game-connection.test.mjs | 38 ++++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/js/src/game/connection.ts b/js/src/game/connection.ts index 61a12614..c7a4be5b 100644 --- a/js/src/game/connection.ts +++ b/js/src/game/connection.ts @@ -585,6 +585,14 @@ export class GameConnection { break; case 'Error': this.#onError(attempt, message); + if (!attempt.welcomed && this.#attempt === attempt) { + // A refused Hello ends the attempt: nothing else follows it. + // Do not wait for the server's close (a host may never send + // one, e.g. a restarted zone refusing a void token). + this.#attempt = undefined; + attempt.socket.close(1000, 'refused'); + this.#onSocketClosed(attempt, 1008, message.message); + } break; } } diff --git a/js/tests/game-connection.test.mjs b/js/tests/game-connection.test.mjs index a678d3fc..df489def 100644 --- a/js/tests/game-connection.test.mjs +++ b/js/tests/game-connection.test.mjs @@ -20,7 +20,7 @@ import { } from './fixtures/game-harness.mjs'; /** A scripted zone: tickets `t::` admit; tokens rotate per Welcome. */ -function scriptedServer(network, { graceTokens = true } = {}) { +function scriptedServer(network, { graceTokens = true, closeOnError = true } = {}) { const tokens = new Set(); let issued = 0; network.onMessage = (conn, message) => { @@ -28,12 +28,12 @@ function scriptedServer(network, { graceTokens = true } = {}) { if (message.reconnectToken !== '') { if (!graceTokens || !tokens.delete(message.reconnectToken)) { conn.send({ type: 'Error', code: GameErrorCode.RECONNECT_FAILED, message: 'expired' }); - conn.close(1008); + if (closeOnError) conn.close(1008); return; } } else if (!message.ticket.startsWith(`t:${conn.zoneId}`) && !message.ticket.startsWith('transfer:')) { conn.send({ type: 'Error', code: GameErrorCode.UNAUTHORIZED, message: 'bad ticket' }); - conn.close(1008); + if (closeOnError) conn.close(1008); return; } issued += 1; @@ -215,6 +215,38 @@ test('reconnects with the token inside the grace window, then falls back to a fr conn.close(); }); +test('a refused Hello ends the attempt even when the server never closes the socket', async () => { + // E.g. a restarted zone refusing the old session's token: the error + // arrives, the close may not (seen with celld room objects). + const network = new FakeNetwork(); + const scheduler = new FakeScheduler(); + const tokens = scriptedServer(network, { closeOnError: false }); + const { conn, tickets } = connection(network, scheduler); + await conn.connect(); + tokens.clear(); + network.last().close(1006); + await scheduler.advance(10); + assert.equal(network.last().lastHello.reconnectToken, 'rt-1', 'tried the token'); + await scheduler.advance(1000); + const hello = network.last().lastHello; + assert.equal(hello.reconnectToken, '', 'then a fresh ticket'); + assert.equal(hello.ticket, `t:z1:${tickets.length}`); + assert.equal(conn.state, 'open'); + conn.close(); + + // A refused first Hello rejects connect() instead of hanging. + const refused = new GameConnection({ + url: 'ws://game.test/zones/z1', + zoneId: 'z1', + ticket: () => 'forged', + socket: network.factory, + scheduler, + reconnect: false + }); + await assert.rejects(refused.connect(), /bad ticket|rejected/); + assert.equal(refused.state, 'closed'); +}); + test('a zone that restarted (close 1012) is rejoined at once with a fresh ticket', async () => { const network = new FakeNetwork(); const scheduler = new FakeScheduler(); From bf01e20209aa7165be8aef64f589472311bf882b Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 23:04:06 -0500 Subject: [PATCH 12/87] fix(game): Arena and Endless Grid keep the body on a reload A page reload is a fresh ticket for a slot its principal still holds. The default Zone::on_rejoin (leave, then join) respawned the player, so every reload lost the body even inside the reconnect grace. Arena and Endless Grid now keep it and re-greet the new page; the on_rejoin docs say why a game wants that. Refs [[tasks/gr-p45-same-player-after-restarts]] --- distributed_game/src/zone.rs | 6 +- examples/game/server/src/arena/mod.rs | 85 ++++++++++++++++++++++++--- examples/game/server/src/grid/mod.rs | 60 +++++++++++++++++++ 3 files changed, 140 insertions(+), 11 deletions(-) diff --git a/distributed_game/src/zone.rs b/distributed_game/src/zone.rs index a6e2710b..984631db 100644 --- a/distributed_game/src/zone.rs +++ b/distributed_game/src/zone.rs @@ -249,8 +249,10 @@ pub trait Zone: Sized + Send + 'static { /// state the zone sent on join (own entity id, hello events…). /// /// Defaults to `on_leave(Rejoined)` then `on_join`, i.e. a fresh join in - /// the same [`PlayerId`]. Override to keep in-zone state and only resend - /// what the client needs. + /// the same [`PlayerId`]: the body is respawned. Override to keep + /// in-zone state and only resend what the client needs, so a reloaded + /// page takes over the same body (what players expect; the examples' + /// Mob Pit, Arena and Endless Grid do). fn on_rejoin(&mut self, ctx: &mut Ctx<'_, Self>, player: PlayerId) { self.on_leave(ctx, player, LeaveReason::Rejoined); self.on_join(ctx, player); diff --git a/examples/game/server/src/arena/mod.rs b/examples/game/server/src/arena/mod.rs index b1507883..8ffe5e1c 100644 --- a/examples/game/server/src/arena/mod.rs +++ b/examples/game/server/src/arena/mod.rs @@ -206,6 +206,24 @@ pub struct ArenaConfig { 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, @@ -294,15 +312,17 @@ impl Zone for Arena { }, }, ); - ctx.set_view(player, View::new(x, y, VIEW_RADIUS)); - ctx.send( - player, - &ArenaEvent::Hello { - entity, - view_radius: VIEW_RADIUS, - half: movement::HALF, - }, - ); + 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) { @@ -395,3 +415,50 @@ impl Demo for ArenaDemo { ) } } + +#[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/grid/mod.rs b/examples/game/server/src/grid/mod.rs index 55b77460..d778fd50 100644 --- a/examples/game/server/src/grid/mod.rs +++ b/examples/game/server/src/grid/mod.rs @@ -461,6 +461,21 @@ impl Zone for GridZone { } } + /// 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); } @@ -679,4 +694,49 @@ mod tests { 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"); + } } From 60536d263eedfae629c86e99c6cafb0b65d8bff8 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 23:04:07 -0500 Subject: [PATCH 13/87] fix(game): keep dev ticket secrets across demo restarts run.sh gave Arena, Grid, Ripple, Gallery and Freeze a random ticket secret per run, so a returning browser's guest token no longer verified and it became a new player. Every demo now signs with a secret persisted in .data/ (SvelteKit also keeps its Auth.js secret), and the cells worker is rebuilt when the room or sim crates change. Refs [[tasks/gr-p45-same-player-after-restarts]] --- examples/game/run.sh | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/examples/game/run.sh b/examples/game/run.sh index 210b2b85..0fcd48b5 100755 --- a/examples/game/run.sh +++ b/examples/game/run.sh @@ -123,7 +123,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" "$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" "$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 @@ -245,10 +245,12 @@ ensure_wasm() { return "$status" } -# mobpit_secret : the rooms' ticket secret, persisted in (the -# services read the same file: guests keep their identity across restarts). -mobpit_secret() { - local file="$1/dev-ticket-secret" +# dev_secret [name]: a dev secret persisted in / (default +# dev-ticket-secret: the ticket secret; Mob Pit's services read the same +# file). Persisted so a returning browser is the same player after a +# restart: its guest token (or session cookie) still verifies. +dev_secret() { + local file="$1/${2:-dev-ticket-secret}" mkdir -p "$1" if [[ ! -s "$file" ]]; then (umask 077 && od -An -N32 -tx1 /dev/urandom | tr -d ' \n' >"$file") @@ -378,7 +380,7 @@ run_mobpit() { ensure_server ensure_wasm local secret - secret="$(mobpit_secret "$MOBPIT_DATA_DIR")" + secret="$(dev_secret "$MOBPIT_DATA_DIR")" CELLD_PID="" FLEET_PROJECT="" if [[ "$fleet" == --fleet ]]; then @@ -419,9 +421,11 @@ run_demo() { [[ "$demo" == mobpit || "$demo" == leaderboard ]] && project="$MOBPIT_DATA_DIR/celld" start_celld "$project" "${CELLD_PORT:-$((port + 3))}" "$port" fi - local log_file + local log_file secret log_file="$(mktemp -t game-demos.XXXXXX)" - "$BIN" "$demo" --dev --port "$port" >"$log_file" 2>&1 & + 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 @@ -470,7 +474,7 @@ run_cluster() { 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:-$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')}" + 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 \ @@ -539,7 +543,7 @@ run_sveltekit() { ensure_cli mkdir -p "$data" local secret - secret="$(mobpit_secret "$data")" + secret="$(dev_secret "$data")" CELLD_PID="" FLEET_PROJECT="" if [[ "$fleet" == --fleet ]]; then @@ -569,7 +573,7 @@ run_sveltekit() { MOBPIT_PROTOCOL_KEY="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')" \ MOBPIT_UI_ORIGINS="http://localhost:$ui_port,http://127.0.0.1:$ui_port" \ MOBPIT_GRAPHQL_URL="http://127.0.0.1:$service_port/graphql" \ - GAME_DEV_AUTH=1 AUTH_SECRET="${AUTH_SECRET:-$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')}" \ + GAME_DEV_AUTH=1 AUTH_SECRET="${AUTH_SECRET:-$(dev_secret "$data" auth-secret)}" \ GAME_TICKET_SECRET="$secret" GAME_ZONES_URL="$(echo "$ROOMS_URLS" | sed 's#http://#ws://#g')" \ "$CLI" dev "$SERVER" } From 123fa992016c6aca85d5594b65f6e3c8b6991ac0 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 20 Oct 2026 23:10:18 -0500 Subject: [PATCH 14/87] feat(game): Mob Pit players come back where they were A reload, a new tab, closing the browser, or a services or celld restart put a returning Mob Pit player in a new body at the entrance spawn point: - the rooms used the default rejoin (leave + join), discarding the body; - nothing chose the player's last room (Vite joined ?zone= or pit.0.0, the SvelteKit ticket handler the entrance); - a room forgot a player's place when its slot ended; - the dev guest token lived in sessionStorage (one guest per tab). Rooms now keep the body on rejoin and park the place and HP of a player whose slot ends (and of connected players in every snapshot) by verified principal, so a fresh join resumes there after the room slept or celld restarted. Page loads start in the last room from the presence cell: POST /dev/mobpit/start (guest token verified) for Vite, the ticket handler's zone() for SvelteKit. The guest token moves to localStorage (?player=