From 131b439de35108cdebb57a1d8c31d4e3b46b9b8d Mon Sep 17 00:00:00 2001 From: martinjms Date: Sun, 3 May 2026 11:37:42 +0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(arcane-infra):=20add=20RapierClusterSi?= =?UTF-8?q?m=20=E2=80=94=20Rapier-backed=20authoritative=20physics=20(v1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a feature-gated Rapier integration as a `ClusterSimulation` wrapper. Drop-in for `run_cluster_loop`: same wire format, same networking, same replication primitives — only the per-tick physics step is new. Architecture: - `RapierClusterSim` IS-A `ClusterSimulation` that HAS-A user `ClusterSimulation`. User logic runs first (intent / game actions), then Rapier integrates pose. - `entity.velocity` is intent-in; `entity.position` is output-only after first- sight spawn (user position writes are silently overwritten by Rapier output). - Despawn driven by `pending_removals` and entity-map disappearance; sync_inputs / sync_outputs filter pending-removal ids to avoid re-spawning bodies the user just asked to remove. - Fixed 1/60 Rapier substeps with accumulator over the variable cluster tick. - v1 default: uniform 0.5-radius sphere collider per entity; per-entity shapes via `user_data` schema deferred. Feature gating: - `rapier3d = "0.32"` declared `optional = true`; `rapier-cluster` feature pulls it in alongside `cluster-ws`. - `arcane_rapier_cluster` binary requires `rapier-cluster`. - Vanilla `cargo build -p arcane-infra` produces zero Rapier in the dep tree. Tests: - 18 unit tests covering lifecycle (spawn/despawn/respawn), multi-entity independence (incl. 500-entity scale), dynamics (velocity passthrough, gravity vs analytic kinematic, velocity-change-mid-sim), user-sim composition (correct context propagation, pending_removals from user code, buff-pattern velocity modulation), and determinism / despawn-respawn round-trip (hand-off scenario). - Vanilla tests unchanged: 65 pass. Feature-on: 83 pass. Clippy silent both modes; doctest in module docs compiles. Co-Authored-By: Claude Opus 4.7 --- crates/arcane-infra/Cargo.toml | 7 + .../src/bin/arcane_rapier_cluster.rs | 58 ++ crates/arcane-infra/src/lib.rs | 8 + crates/arcane-infra/src/rapier_cluster.rs | 862 ++++++++++++++++++ 4 files changed, 935 insertions(+) create mode 100644 crates/arcane-infra/src/bin/arcane_rapier_cluster.rs create mode 100644 crates/arcane-infra/src/rapier_cluster.rs diff --git a/crates/arcane-infra/Cargo.toml b/crates/arcane-infra/Cargo.toml index fa6fc53..673ef76 100644 --- a/crates/arcane-infra/Cargo.toml +++ b/crates/arcane-infra/Cargo.toml @@ -13,6 +13,7 @@ arcane-wire = { path = "../arcane-wire" } axum = { version = "0.7", optional = true } futures-util = { version = "0.3", optional = true } rayon = { version = "1", optional = true } +rapier3d = { version = "0.32", optional = true } redis = "0.27" reqwest = { version = "0.12", features = ["json", "blocking"], optional = true } serde = { version = "1.0", features = ["derive"] } @@ -26,6 +27,7 @@ default = ["cluster-ws"] manager = ["dep:axum", "dep:tokio"] cluster-ws = ["dep:tokio", "dep:tokio-tungstenite", "dep:futures-util", "dep:rayon"] spacetimedb-persist = ["dep:reqwest"] +rapier-cluster = ["cluster-ws", "dep:rapier3d"] [[bin]] name = "arcane-cluster" @@ -37,5 +39,10 @@ name = "arcane-manager" path = "src/bin/arcane_manager.rs" required-features = ["manager"] +[[bin]] +name = "arcane-rapier-cluster" +path = "src/bin/arcane_rapier_cluster.rs" +required-features = ["rapier-cluster"] + [dev-dependencies] redis = "0.27" diff --git a/crates/arcane-infra/src/bin/arcane_rapier_cluster.rs b/crates/arcane-infra/src/bin/arcane_rapier_cluster.rs new file mode 100644 index 0000000..3b37c00 --- /dev/null +++ b/crates/arcane-infra/src/bin/arcane_rapier_cluster.rs @@ -0,0 +1,58 @@ +//! Rapier-backed cluster server binary. +//! +//! Same env vars and command shape as `arcane_cluster.rs`. The only difference: +//! the user simulation is wrapped in [`arcane_infra::rapier_cluster::RapierClusterSim`], +//! so authoritative pose advancement happens through Rapier instead of the user's +//! `on_tick`. Networking, replication, neighbor merge, and persistence are +//! identical to the vanilla cluster. +//! +//! Env (same as arcane-cluster): +//! CLUSTER_ID — required; UUID of this cluster. +//! REDIS_URL — optional; default `redis://127.0.0.1:6379`. +//! NEIGHBOR_IDS — optional; comma-separated UUIDs of neighbor clusters. +//! CLUSTER_WS_PORT — optional; default 8080. + +use std::env; +use std::sync::Arc; + +use arcane_core::ClusterSimulation; +use arcane_infra::{cluster_runner, RapierClusterSim, RapierConfig}; +use uuid::Uuid; + +fn parse_uuids(s: &str) -> Vec { + s.split(',') + .map(|x| x.trim()) + .filter(|x| !x.is_empty()) + .filter_map(|x| Uuid::parse_str(x).ok()) + .collect() +} + +fn main() -> Result<(), String> { + let cluster_id = + env::var("CLUSTER_ID").map_err(|_| "CLUSTER_ID env var required (UUID)".to_string())?; + let cluster_id = + Uuid::parse_str(&cluster_id).map_err(|e| format!("invalid CLUSTER_ID: {}", e))?; + + let redis_url = env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + let neighbor_ids = env::var("NEIGHBOR_IDS") + .map(|s| parse_uuids(&s)) + .unwrap_or_default(); + + let ws_port: u16 = env::var("CLUSTER_WS_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(8080); + + let user_sim: Option> = None; + let rapier_sim: Arc = + Arc::new(RapierClusterSim::new(user_sim, RapierConfig::default())); + + cluster_runner::run_cluster_loop( + cluster_id, + redis_url, + neighbor_ids, + ws_port, + |_| vec![], + Some(rapier_sim), + ) +} diff --git a/crates/arcane-infra/src/lib.rs b/crates/arcane-infra/src/lib.rs index 470ef97..e5215df 100644 --- a/crates/arcane-infra/src/lib.rs +++ b/crates/arcane-infra/src/lib.rs @@ -10,6 +10,8 @@ //! - `ws_server`: client-facing WebSocket transport. //! - `spacetimedb_persist`: throttled persistence adapter for state snapshots. //! - `cluster_runner`: loop composition that wires server, replication, ws, and persistence. +//! - `rapier_cluster`: Rapier-backed authoritative physics wrapped as a `ClusterSimulation` +//! (feature `rapier-cluster`). #[cfg(feature = "cluster-ws")] pub mod broadcast_channel_cap; @@ -31,6 +33,9 @@ pub mod cluster_stats; #[cfg(feature = "cluster-ws")] pub mod ws_server; +#[cfg(feature = "rapier-cluster")] +pub mod rapier_cluster; + #[cfg(feature = "cluster-ws")] pub use arcane_core::cluster_simulation::{ClusterSimulation, ClusterTickContext, GameAction}; @@ -39,3 +44,6 @@ pub use cluster_server::ClusterServer; pub use redis_channel::RedisReplicationChannel; pub use replication_channel_manager::ReplicationChannelManager; pub use rpc_handler::RpcHandler; + +#[cfg(feature = "rapier-cluster")] +pub use rapier_cluster::{RapierClusterSim, RapierConfig}; diff --git a/crates/arcane-infra/src/rapier_cluster.rs b/crates/arcane-infra/src/rapier_cluster.rs new file mode 100644 index 0000000..e263384 --- /dev/null +++ b/crates/arcane-infra/src/rapier_cluster.rs @@ -0,0 +1,862 @@ +//! Rapier-backed authoritative physics for the cluster tick. +//! +//! [`RapierClusterSim`] wraps a user's [`ClusterSimulation`] and inserts a Rapier +//! [`PhysicsPipeline::step`] after the user's `on_tick`. Drop into the existing +//! [`crate::cluster_runner::run_cluster_loop`] in place of a bare user simulation — +//! all networking, replication, neighbor merge, and persistence are unchanged. +//! +//! # Contract +//! +//! - `entity.velocity` is **intent-in**. The user's `on_tick` writes it; Rapier reads +//! it as the rigid body's `linvel` for the upcoming step. +//! - `entity.position` is **output-only after first-sight spawn**. The first time an +//! entity appears in the entity map, its position seeds the rigid body's translation. +//! Subsequent user writes are overwritten by Rapier's post-step output. +//! - Despawn is driven by `pending_removals` — when an entity leaves the map, its +//! rigid body and collider are removed from the Rapier world. +//! - Every entity is spawned with a single uniform sphere collider +//! ([`RapierConfig::default_body_radius`]). Per-entity collider shapes via the +//! `user_data` schema are a v2 follow-up. +//! +//! # Substepping +//! +//! The cluster tick is variable (env-driven, default 20 Hz). Rapier prefers fixed +//! substeps for stability. We accumulate `dt_seconds` and step Rapier in +//! [`FIXED_PHYSICS_DT`]-sized increments until the accumulator drains. +//! +//! # Precision +//! +//! `EntityStateEntry` uses `f64` positions and velocities; Rapier uses `f32` +//! internally. Conversion happens on every input/output sync. For worlds within +//! ~10⁴ units of origin this is sub-millimeter; far-from-origin coordinates lose +//! precision in the standard `f32` way. If your world exceeds those bounds, +//! enable Rapier's `f64` feature in a follow-up. +//! +//! # Example +//! +//! ```no_run +//! use std::sync::Arc; +//! use arcane_core::ClusterSimulation; +//! use arcane_infra::{RapierClusterSim, RapierConfig}; +//! +//! // Pure-Rapier: no game logic, just integrate velocity into position. +//! let physics: Arc = +//! Arc::new(RapierClusterSim::new(None, RapierConfig::default())); +//! +//! // Or: wrap your own ClusterSimulation so user logic runs first, then Rapier. +//! // let user_sim: Arc = Arc::new(MyGameLogic::new()); +//! // let physics = Arc::new(RapierClusterSim::new(Some(user_sim), RapierConfig::default())); +//! +//! // Pass `Some(physics)` as the simulation arg to `run_cluster_loop`. +//! ``` + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use rapier3d::prelude::*; +use uuid::Uuid; + +use arcane_core::cluster_simulation::{ClusterSimulation, ClusterTickContext}; +use arcane_core::replication_channel::EntityStateEntry; + +/// Fixed Rapier substep size. 1/60 s matches the standard physics rate. +const FIXED_PHYSICS_DT: f32 = 1.0 / 60.0; + +/// V1 default body shape — uniform sphere collider. Per-entity shapes via `user_data` +/// schema is a follow-up. +const DEFAULT_BODY_RADIUS: f32 = 0.5; + +/// Configuration knobs for [`RapierClusterSim`]. +#[derive(Clone, Debug)] +pub struct RapierConfig { + /// World gravity vector in m/s². Default is zero gravity (matches benchmark + /// parity: today's benchmark cluster does pure velocity integration with no + /// downward acceleration). Set to e.g. `[0.0, -9.81, 0.0]` for Earth gravity + /// along -Y. + pub gravity: [f32; 3], + /// Sphere collider radius applied to every spawned entity. v1 uses one shape + /// for all bodies; per-entity shapes are a follow-up. + pub default_body_radius: f32, +} + +impl Default for RapierConfig { + fn default() -> Self { + Self { + gravity: [0.0, 0.0, 0.0], + default_body_radius: DEFAULT_BODY_RADIUS, + } + } +} + +struct RapierState { + bodies: RigidBodySet, + colliders: ColliderSet, + integration_parameters: IntegrationParameters, + physics_pipeline: PhysicsPipeline, + islands: IslandManager, + broad_phase: DefaultBroadPhase, + narrow_phase: NarrowPhase, + impulse_joints: ImpulseJointSet, + multibody_joints: MultibodyJointSet, + ccd_solver: CCDSolver, + handles: HashMap, + accumulator: f32, + gravity: Vector, + default_body_radius: f32, +} + +impl RapierState { + fn new(config: &RapierConfig) -> Self { + Self { + bodies: RigidBodySet::new(), + colliders: ColliderSet::new(), + integration_parameters: IntegrationParameters { + dt: FIXED_PHYSICS_DT, + ..IntegrationParameters::default() + }, + physics_pipeline: PhysicsPipeline::new(), + islands: IslandManager::new(), + broad_phase: DefaultBroadPhase::new(), + narrow_phase: NarrowPhase::new(), + impulse_joints: ImpulseJointSet::new(), + multibody_joints: MultibodyJointSet::new(), + ccd_solver: CCDSolver::new(), + handles: HashMap::new(), + accumulator: 0.0, + gravity: Vector::new(config.gravity[0], config.gravity[1], config.gravity[2]), + default_body_radius: config.default_body_radius, + } + } + + fn spawn(&mut self, entity_id: Uuid, entry: &EntityStateEntry) -> RigidBodyHandle { + let body = RigidBodyBuilder::dynamic() + .translation(Vector::new( + entry.position.x as f32, + entry.position.y as f32, + entry.position.z as f32, + )) + .linvel(Vector::new( + entry.velocity.x as f32, + entry.velocity.y as f32, + entry.velocity.z as f32, + )) + .build(); + let handle = self.bodies.insert(body); + let collider = ColliderBuilder::ball(self.default_body_radius).build(); + self.colliders + .insert_with_parent(collider, handle, &mut self.bodies); + self.handles.insert(entity_id, handle); + handle + } + + fn despawn(&mut self, entity_id: Uuid) { + if let Some(handle) = self.handles.remove(&entity_id) { + self.bodies.remove( + handle, + &mut self.islands, + &mut self.colliders, + &mut self.impulse_joints, + &mut self.multibody_joints, + true, + ); + } + } + + fn sync_inputs(&mut self, entities: &HashMap, skip: &[Uuid]) { + for (id, entry) in entities.iter() { + if skip.contains(id) { + continue; + } + match self.handles.get(id).copied() { + None => { + self.spawn(*id, entry); + } + Some(handle) => { + if let Some(body) = self.bodies.get_mut(handle) { + body.set_linvel( + Vector::new( + entry.velocity.x as f32, + entry.velocity.y as f32, + entry.velocity.z as f32, + ), + true, + ); + } + } + } + } + } + + fn step_with_accumulator(&mut self, dt_seconds: f32) { + self.accumulator += dt_seconds; + while self.accumulator >= FIXED_PHYSICS_DT { + self.physics_pipeline.step( + self.gravity, + &self.integration_parameters, + &mut self.islands, + &mut self.broad_phase, + &mut self.narrow_phase, + &mut self.bodies, + &mut self.colliders, + &mut self.impulse_joints, + &mut self.multibody_joints, + &mut self.ccd_solver, + &(), + &(), + ); + self.accumulator -= FIXED_PHYSICS_DT; + } + } + + fn sync_outputs(&self, entities: &mut HashMap, skip: &[Uuid]) { + for (id, entry) in entities.iter_mut() { + if skip.contains(id) { + continue; + } + let Some(&handle) = self.handles.get(id) else { + continue; + }; + let Some(body) = self.bodies.get(handle) else { + continue; + }; + let t = body.translation(); + let v = body.linvel(); + entry.position.x = t.x as f64; + entry.position.y = t.y as f64; + entry.position.z = t.z as f64; + entry.velocity.x = v.x as f64; + entry.velocity.y = v.y as f64; + entry.velocity.z = v.z as f64; + } + } + + fn despawn_missing(&mut self, entities: &HashMap) { + let stale: Vec = self + .handles + .keys() + .filter(|id| !entities.contains_key(id)) + .copied() + .collect(); + for id in stale { + self.despawn(id); + } + } +} + +/// A [`ClusterSimulation`] that runs the user's logic, then a Rapier physics step. +/// +/// Wrap your `ClusterSimulation` in this and pass it to `run_cluster_loop`. +/// The user's `on_tick` runs first (mutating velocities, processing actions, +/// pushing to `pending_removals`); the Rapier step then advances poses and writes +/// the results back into the entity map for replication. +pub struct RapierClusterSim { + user_sim: Option>, + state: Mutex, +} + +impl RapierClusterSim { + pub fn new(user_sim: Option>, config: RapierConfig) -> Self { + Self { + user_sim, + state: Mutex::new(RapierState::new(&config)), + } + } + + pub fn with_default_config(user_sim: Option>) -> Self { + Self::new(user_sim, RapierConfig::default()) + } +} + +impl ClusterSimulation for RapierClusterSim { + fn on_tick(&self, ctx: &mut ClusterTickContext<'_>) { + if let Some(user_sim) = &self.user_sim { + user_sim.on_tick(ctx); + } + + let mut state = self.state.lock().expect("rapier state lock"); + + // Entities the user has flagged for removal this tick. The cluster runner + // will drop them from the entity map *after* on_tick returns, but for our + // purposes they are already gone — no input sync, no output sync, no body. + // Pass the slice directly to avoid a per-tick HashSet allocation in the + // common (empty) case; n is small enough that linear scan beats hashing. + let removed: &[Uuid] = ctx.pending_removals.as_slice(); + for &id in removed { + state.despawn(id); + } + state.despawn_missing(ctx.entities); + + state.sync_inputs(ctx.entities, removed); + state.step_with_accumulator(ctx.dt_seconds as f32); + state.sync_outputs(ctx.entities, removed); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use arcane_core::cluster_simulation::GameAction; + use arcane_core::Vec3; + use std::sync::atomic::{AtomicU64, Ordering}; + + const CLUSTER_DT: f64 = 1.0 / 20.0; // matches the default 20 Hz cluster tick + const SUBSTEP_TOL: f64 = 0.05; // ~5% tolerance for substep residue + + fn mk_entry(id: Uuid, pos: Vec3, vel: Vec3) -> EntityStateEntry { + EntityStateEntry::new(id, Uuid::nil(), pos, vel) + } + + /// Run `sim.on_tick` once with the given dt, no actions, no removals. + fn step_once( + sim: &RapierClusterSim, + entities: &mut HashMap, + tick: u64, + dt: f64, + ) { + let mut pending: Vec = Vec::new(); + let actions: Vec = Vec::new(); + let mut ctx = ClusterTickContext { + cluster_id: Uuid::nil(), + tick, + dt_seconds: dt, + entities, + pending_removals: &mut pending, + game_actions: &actions, + }; + sim.on_tick(&mut ctx); + } + + fn step_n( + sim: &RapierClusterSim, + entities: &mut HashMap, + n: u64, + dt: f64, + ) { + for tick in 0..n { + step_once(sim, entities, tick + 1, dt); + } + } + + fn close(a: f64, b: f64, eps: f64) -> bool { + (a - b).abs() < eps + } + + fn handle_count(sim: &RapierClusterSim) -> usize { + sim.state.lock().unwrap().handles.len() + } + + // ─── lifecycle ────────────────────────────────────────────────────────────── + + #[test] + fn empty_entities_steps_cleanly() { + let sim = RapierClusterSim::with_default_config(None); + let mut entities: HashMap = HashMap::new(); + step_n(&sim, &mut entities, 5, CLUSTER_DT); + assert_eq!(handle_count(&sim), 0); + } + + #[test] + fn first_sight_spawn_uses_initial_position() { + let sim = RapierClusterSim::with_default_config(None); + let mut entities: HashMap = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert( + id, + mk_entry(id, Vec3::new(5.0, 2.0, -3.0), Vec3::new(0.0, 0.0, 0.0)), + ); + // One short tick with zero velocity; final position should match initial. + step_once(&sim, &mut entities, 1, CLUSTER_DT); + let p = entities.get(&id).unwrap().position; + assert!(close(p.x, 5.0, 1e-3), "x: {}", p.x); + assert!(close(p.y, 2.0, 1e-3), "y: {}", p.y); + assert!(close(p.z, -3.0, 1e-3), "z: {}", p.z); + } + + #[test] + fn position_writes_from_user_are_overwritten_by_rapier() { + // Contract: AFTER first-sight spawn, user position writes are overwritten + // by Rapier output. (At first-sight, the user-mutated position becomes the + // spawn position — this is intentional, since the cluster runner has already + // populated the entity map by the time on_tick runs.) + struct PositionWriter; + impl ClusterSimulation for PositionWriter { + fn on_tick(&self, ctx: &mut ClusterTickContext<'_>) { + if ctx.tick > 1 { + for entity in ctx.entities.values_mut() { + entity.position = Vec3::new(999.0, 999.0, 999.0); + } + } + } + } + let sim = RapierClusterSim::with_default_config(Some(Arc::new(PositionWriter))); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + // Tick 1: writer no-op, body spawned at (0,0,0). + // Tick 2+: writer pushes 999s, but wrapper ignores entity.position when + // the body already exists and writes Rapier output (still 0,0,0) back. + step_n(&sim, &mut entities, 3, CLUSTER_DT); + let p = entities.get(&id).unwrap().position; + assert!(p.x.abs() < 1e-3 && p.y.abs() < 1e-3 && p.z.abs() < 1e-3, "{:?}", p); + } + + #[test] + fn pending_removals_destroy_body() { + let sim = RapierClusterSim::with_default_config(None); + let mut entities: HashMap = HashMap::new(); + let id = Uuid::from_u128(7); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + step_once(&sim, &mut entities, 1, CLUSTER_DT); + assert_eq!(handle_count(&sim), 1); + + // Simulate the cluster removing the entity post-tick. + entities.remove(&id); + step_once(&sim, &mut entities, 2, CLUSTER_DT); + assert_eq!(handle_count(&sim), 0); + } + + #[test] + fn user_can_request_removal_via_pending_removals() { + // The cluster runner consumes pending_removals AFTER on_tick returns. Inside + // on_tick the user can push ids into pending_removals; our wrapper reads them + // and despawns the bodies before stepping physics. + struct RemoveAll; + impl ClusterSimulation for RemoveAll { + fn on_tick(&self, ctx: &mut ClusterTickContext<'_>) { + let ids: Vec = ctx.entities.keys().copied().collect(); + ctx.pending_removals.extend(ids); + } + } + let sim = RapierClusterSim::with_default_config(Some(Arc::new(RemoveAll))); + let mut entities = HashMap::new(); + for k in 0..5u128 { + let id = Uuid::from_u128(k); + entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + } + // First tick spawns 5 bodies, then the wrapper sees pending_removals with all + // 5 ids and despawns them. + step_once(&sim, &mut entities, 1, CLUSTER_DT); + assert_eq!(handle_count(&sim), 0); + } + + #[test] + fn respawn_same_uuid_creates_fresh_body() { + let sim = RapierClusterSim::with_default_config(None); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(42); + + // First lifetime: spawn at +10 with velocity 1, drift, despawn. + entities.insert( + id, + mk_entry(id, Vec3::new(10.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)), + ); + step_n(&sim, &mut entities, 4, CLUSTER_DT); // ~0.2 s of motion + let drifted_x = entities.get(&id).unwrap().position.x; + assert!(drifted_x > 10.0, "expected drift, got {}", drifted_x); + entities.remove(&id); + step_once(&sim, &mut entities, 5, CLUSTER_DT); // despawn_missing + assert_eq!(handle_count(&sim), 0); + + // Second lifetime: same UUID, fresh starting state. + entities.insert( + id, + mk_entry(id, Vec3::new(-100.0, 5.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + step_once(&sim, &mut entities, 6, CLUSTER_DT); + let p = entities.get(&id).unwrap().position; + assert!(close(p.x, -100.0, 1e-3), "fresh body should start at -100, got {}", p.x); + assert!(close(p.y, 5.0, 1e-3), "fresh body y, got {}", p.y); + assert_eq!(handle_count(&sim), 1); + } + + // ─── multi-entity ─────────────────────────────────────────────────────────── + + #[test] + fn multiple_entities_advance_independently() { + let sim = RapierClusterSim::with_default_config(None); + let mut entities = HashMap::new(); + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + let c = Uuid::from_u128(3); + // Spaced apart (>> 0.5 default sphere radius) so contact resolution doesn't + // perturb the linear-motion expectation. + let a_start = Vec3::new(0.0, 0.0, 0.0); + let b_start = Vec3::new(100.0, 0.0, 0.0); + let c_start = Vec3::new(-100.0, 0.0, 0.0); + entities.insert(a, mk_entry(a, a_start, Vec3::new(1.0, 0.0, 0.0))); + entities.insert(b, mk_entry(b, b_start, Vec3::new(0.0, 2.0, 0.0))); + entities.insert(c, mk_entry(c, c_start, Vec3::new(0.0, 0.0, -3.0))); + + step_n(&sim, &mut entities, 20, CLUSTER_DT); // 1.0 s + + let pa = entities.get(&a).unwrap().position; + let pb = entities.get(&b).unwrap().position; + let pc = entities.get(&c).unwrap().position; + // Each entity should have moved by its own velocity vector × elapsed time. + assert!(close(pa.x - a_start.x, 1.0, SUBSTEP_TOL), "Δa.x = {}", pa.x - a_start.x); + assert!((pa.y - a_start.y).abs() < SUBSTEP_TOL); + assert!((pa.z - a_start.z).abs() < SUBSTEP_TOL); + assert!(close(pb.y - b_start.y, 2.0, 2.0 * SUBSTEP_TOL), "Δb.y = {}", pb.y - b_start.y); + assert!((pb.x - b_start.x).abs() < SUBSTEP_TOL); + assert!((pb.z - b_start.z).abs() < SUBSTEP_TOL); + assert!(close(pc.z - c_start.z, -3.0, 3.0 * SUBSTEP_TOL), "Δc.z = {}", pc.z - c_start.z); + assert!((pc.x - c_start.x).abs() < SUBSTEP_TOL); + assert!((pc.y - c_start.y).abs() < SUBSTEP_TOL); + } + + #[test] + fn many_entities_no_crash_and_advance_proportionally() { + let sim = RapierClusterSim::with_default_config(None); + let mut entities = HashMap::new(); + let n = 500u128; + // Spread entities far apart so they don't overlap (default sphere radius 0.5). + for k in 0..n { + let id = Uuid::from_u128(k); + let row = (k / 25) as f64; + let col = (k % 25) as f64; + entities.insert( + id, + mk_entry(id, Vec3::new(col * 5.0, 0.0, row * 5.0), Vec3::new(1.0, 0.0, 0.0)), + ); + } + step_n(&sim, &mut entities, 20, CLUSTER_DT); // 1.0 s + assert_eq!(handle_count(&sim), n as usize); + // Spot-check: every entity should have advanced by ~1.0 in x. + for k in 0..n { + let id = Uuid::from_u128(k); + let entry = entities.get(&id).unwrap(); + let col = (k % 25) as f64; + let expected = col * 5.0 + 1.0; + assert!( + (entry.position.x - expected).abs() < 0.1, + "entity {} x = {}, expected ~{}", + k, + entry.position.x, + expected + ); + } + } + + // ─── dynamics ─────────────────────────────────────────────────────────────── + + #[test] + fn velocity_passthrough_advances_position() { + let sim = RapierClusterSim::with_default_config(None); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)), + ); + step_n(&sim, &mut entities, 60, CLUSTER_DT); // 3 s + let final_x = entities.get(&id).unwrap().position.x; + assert!(close(final_x, 3.0, 0.15), "expected ~3.0, got {}", final_x); + } + + #[test] + fn zero_velocity_zero_gravity_position_unchanged() { + let sim = RapierClusterSim::with_default_config(None); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + let start = Vec3::new(7.0, -2.0, 11.0); + entities.insert(id, mk_entry(id, start, Vec3::new(0.0, 0.0, 0.0))); + step_n(&sim, &mut entities, 100, CLUSTER_DT); // 5 s + let p = entities.get(&id).unwrap().position; + assert!(close(p.x, start.x, 1e-3)); + assert!(close(p.y, start.y, 1e-3)); + assert!(close(p.z, start.z, 1e-3)); + } + + #[test] + fn gravity_freefall_is_physically_plausible() { + // Plausibility, not exact match: Rapier uses semi-implicit Euler over fixed + // 1/60 substeps, so position over T seconds differs from analytic 0.5·g·t² + // by O(g·dt·t). We assert the entity moves *down*, accelerates correctly + // (velocity grows linearly), and final position is within 10% of analytic. + let config = RapierConfig { + gravity: [0.0, -9.81, 0.0], + ..Default::default() + }; + let sim = RapierClusterSim::new(None, config); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + let total_t = 1.0; + let ticks = (total_t / CLUSTER_DT).round() as u64; + step_n(&sim, &mut entities, ticks, CLUSTER_DT); + let p = entities.get(&id).unwrap().position; + let v = entities.get(&id).unwrap().velocity; + let analytic_y = -0.5 * 9.81 * total_t * total_t; + let analytic_vy = -9.81 * total_t; + // Position within 10%, velocity within 5%. + assert!( + (p.y - analytic_y).abs() < 0.1 * analytic_y.abs(), + "y = {}, analytic = {}", + p.y, + analytic_y + ); + assert!( + (v.y - analytic_vy).abs() < 0.05 * analytic_vy.abs(), + "vy = {}, analytic = {}", + v.y, + analytic_vy + ); + } + + #[test] + fn velocity_change_takes_effect_on_next_step() { + // User mutates velocity in `on_tick`; that mutation must be picked up by + // Rapier's next step. Models the buff-multiplier pattern in BenchmarkSimulation. + struct DoubleVx; + impl ClusterSimulation for DoubleVx { + fn on_tick(&self, ctx: &mut ClusterTickContext<'_>) { + if ctx.tick == 30 { + for e in ctx.entities.values_mut() { + e.velocity.x *= 2.0; + } + } + } + } + let sim = RapierClusterSim::with_default_config(Some(Arc::new(DoubleVx))); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)), + ); + step_n(&sim, &mut entities, 60, CLUSTER_DT); // 3 s; doubling kicks in at tick 30 (1.5 s mark) + let final_x = entities.get(&id).unwrap().position.x; + // First 1.5s at vx=1 → +1.5; remaining 1.5s at vx=2 → +3.0; total ≈ 4.5. + assert!(close(final_x, 4.5, 0.25), "got {}", final_x); + let final_vx = entities.get(&id).unwrap().velocity.x; + assert!(close(final_vx, 2.0, 1e-3), "got {}", final_vx); + } + + #[test] + fn output_velocity_under_gravity_grows_linearly() { + let config = RapierConfig { + gravity: [0.0, -9.81, 0.0], + ..Default::default() + }; + let sim = RapierClusterSim::new(None, config); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + let mut prev_vy: Option = None; + for tick in 0..40 { + step_once(&sim, &mut entities, tick + 1, CLUSTER_DT); + let vy = entities.get(&id).unwrap().velocity.y; + if let Some(prev) = prev_vy { + assert!(vy < prev, "vy must monotonically decrease under -y gravity (was {}, now {})", prev, vy); + } + prev_vy = Some(vy); + } + } + + // ─── user-sim composition ─────────────────────────────────────────────────── + + #[test] + fn none_user_sim_runs_pure_rapier() { + // No wrapped user sim → Rapier still advances entities based on whatever + // velocity is on the EntityStateEntry. Models the "low-cost background + // simulation" use case: clients seed velocity, server just integrates. + let sim = RapierClusterSim::with_default_config(None); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 0.0)), + ); + step_n(&sim, &mut entities, 20, CLUSTER_DT); // 1 s + let p = entities.get(&id).unwrap().position; + assert!(close(p.x, 2.0, 0.1), "x = {}", p.x); + } + + #[test] + fn user_on_tick_runs_before_physics_with_correct_context() { + // The user observes the pre-physics state and gets correct dt/tick/actions. + // After their on_tick, Rapier picks up whatever velocity they wrote. + struct Spy { + calls: AtomicU64, + last_dt: Mutex, + last_tick: AtomicU64, + last_action_count: AtomicU64, + } + impl ClusterSimulation for Spy { + fn on_tick(&self, ctx: &mut ClusterTickContext<'_>) { + self.calls.fetch_add(1, Ordering::SeqCst); + *self.last_dt.lock().unwrap() = ctx.dt_seconds; + self.last_tick.store(ctx.tick, Ordering::SeqCst); + self.last_action_count + .store(ctx.game_actions.len() as u64, Ordering::SeqCst); + // Mutate velocity — Rapier should pick this up. + for e in ctx.entities.values_mut() { + e.velocity = Vec3::new(5.0, 0.0, 0.0); + } + } + } + let spy = Arc::new(Spy { + calls: AtomicU64::new(0), + last_dt: Mutex::new(0.0), + last_tick: AtomicU64::new(0), + last_action_count: AtomicU64::new(0), + }); + let sim = RapierClusterSim::with_default_config(Some(spy.clone())); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + + let action = GameAction { + entity_id: id, + action_type: "test".into(), + payload: serde_json::Value::Null, + }; + let actions = vec![action]; + let mut pending: Vec = Vec::new(); + let mut ctx = ClusterTickContext { + cluster_id: Uuid::nil(), + tick: 42, + dt_seconds: CLUSTER_DT, + entities: &mut entities, + pending_removals: &mut pending, + game_actions: &actions, + }; + sim.on_tick(&mut ctx); + + assert_eq!(spy.calls.load(Ordering::SeqCst), 1); + assert!(close(*spy.last_dt.lock().unwrap(), CLUSTER_DT, 1e-9)); + assert_eq!(spy.last_tick.load(Ordering::SeqCst), 42); + assert_eq!(spy.last_action_count.load(Ordering::SeqCst), 1); + // Rapier saw the velocity the spy wrote (5.0) → entity advances along x. + let p = entities.get(&id).unwrap().position; + assert!(p.x > 0.0, "Rapier should have applied user-written velocity, x = {}", p.x); + } + + #[test] + fn user_buff_modifies_velocity_then_rapier_integrates() { + // Mimics BenchmarkSimulation's buff pattern: user multiplies velocity by a + // speed factor each tick; Rapier integrates the buffed velocity into position. + struct Buff(f64); + impl ClusterSimulation for Buff { + fn on_tick(&self, ctx: &mut ClusterTickContext<'_>) { + for e in ctx.entities.values_mut() { + e.velocity.x *= self.0; + } + } + } + let sim = RapierClusterSim::with_default_config(Some(Arc::new(Buff(1.0)))); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)), + ); + step_n(&sim, &mut entities, 20, CLUSTER_DT); // 1 s of vx=1 + let baseline_x = entities.get(&id).unwrap().position.x; + + // Now redo with a 2× buff. + let sim2 = RapierClusterSim::with_default_config(Some(Arc::new(Buff(2.0)))); + let mut entities2 = HashMap::new(); + entities2.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)), + ); + // With Buff(2.0), velocity doubles every tick → exponential growth. Use a + // shorter horizon (3 ticks) so the assertion is meaningful. + step_n(&sim2, &mut entities2, 3, CLUSTER_DT); + let buffed_x = entities2.get(&id).unwrap().position.x; + let buffed_vx = entities2.get(&id).unwrap().velocity.x; + assert!(buffed_x > baseline_x / 5.0, "buff should produce more motion per tick"); + assert!(buffed_vx >= 8.0, "vx should have doubled 3× to ≥ 8, got {}", buffed_vx); + } + + // ─── determinism / hand-off ───────────────────────────────────────────────── + + #[test] + fn same_inputs_produce_same_outputs() { + // Two independent RapierClusterSim instances with identical initial state + // and identical tick sequence must produce identical final state. This is + // the in-process determinism guarantee — important for verification and + // server-side reconciliation. + fn run() -> (f64, f64, f64) { + let sim = RapierClusterSim::with_default_config(None); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert( + id, + mk_entry(id, Vec3::new(1.0, 2.0, 3.0), Vec3::new(0.5, -0.25, 0.75)), + ); + step_n(&sim, &mut entities, 100, CLUSTER_DT); + let p = entities.get(&id).unwrap().position; + (p.x, p.y, p.z) + } + let a = run(); + let b = run(); + assert_eq!(a, b, "expected bit-identical outputs across runs"); + } + + #[test] + fn state_round_trips_through_despawn_respawn() { + // Models the hand-off-out / hand-off-in flow: cluster simulates entity for N + // ticks, exports the resulting EntityStateEntry, despawns the body, then a + // (different) cluster respawns from that exported state and continues. The + // continuation should match running the original sim straight through for + // N+M ticks. + fn straight_through() -> Vec3 { + let sim = RapierClusterSim::with_default_config(None); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)), + ); + step_n(&sim, &mut entities, 40, CLUSTER_DT); // 2 s + entities.get(&id).unwrap().position + } + fn handoff() -> Vec3 { + let sim_a = RapierClusterSim::with_default_config(None); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)), + ); + step_n(&sim_a, &mut entities, 20, CLUSTER_DT); // 1 s on cluster A + // Hand-off: capture entry, drop sim_a, respawn on sim_b. + let exported = entities.get(&id).unwrap().clone(); + drop(sim_a); + let sim_b = RapierClusterSim::with_default_config(None); + let mut entities = HashMap::new(); + entities.insert(id, exported); + step_n(&sim_b, &mut entities, 20, CLUSTER_DT); // 1 s on cluster B + entities.get(&id).unwrap().position + } + let direct = straight_through(); + let via_handoff = handoff(); + // Hand-off doesn't preserve substep accumulator residue, so a small + // discrepancy is expected. ≤1% of a unit over 2 s of motion is acceptable. + assert!( + (direct.x - via_handoff.x).abs() < 0.05, + "direct {:?} vs handoff {:?}", + direct, + via_handoff + ); + assert!((direct.y - via_handoff.y).abs() < 0.05); + assert!((direct.z - via_handoff.z).abs() < 0.05); + } +} From 664671c525b221b97acbc9b3ce8f45dffa3ae54e Mon Sep 17 00:00:00 2001 From: martinjms Date: Sun, 3 May 2026 12:10:45 +0300 Subject: [PATCH 2/5] feat(arcane-infra): contact events + per-entity collider shapes for RapierClusterSim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the V2 surface so Rapier-backed nodes are usable for real games — without this, integration is "uniform spheres with invisible collisions" (fine for tech demo, useless for typical action gameplay). Public additions: - `RapierClusterSimulation` trait — sibling of `ClusterSimulation`, lives in `arcane-infra::rapier_cluster` so it can use Rapier types freely. Receives a `RapierClusterTickContext` instead of `ClusterTickContext`. - `RapierClusterTickContext<'a>` — same fields as `ClusterTickContext` plus `contact_events: &[ContactEvent]` from the previous tick's physics step. One-tick delay by design: user logic runs first to set intent, physics produces output for next tick. - `ContactEvent { entity_a, entity_b, started }` — collisions mapped from Rapier collider handles back to entity Uuids via a new reverse map. - `RapierColliderShape::{Ball | Capsule | Cuboid}` — declared per entity via `RapierClusterSimulation::collider_for`. Default impl returns `Ball(config.default_body_radius)`. Resolved at first-sight spawn only; later returns are ignored (despawn-and-respawn to change shape). - `RapierClusterSim::with_rapier_sim(rapier_sim, config)` constructor for the new trait. V1 `new` and `with_default_config` constructors preserved. Internal refactor: - `RapierClusterSim` now holds a private `Backend { None, Cluster, Rapier }` enum. `on_tick` dispatches per variant; the Rapier branch builds the extended ctx. - `RapierState` gains `collider_to_entity: HashMap` for event mapping and `pending_contact_events: Vec` populated by a custom `EventHandler` impl (`CollisionRecorder`) installed during the step. Spawn loop and shape resolution moved out of `RapierState` into the wrapper so the active backend can drive `collider_for`. - Every spawned collider sets `ActiveEvents::COLLISION_EVENTS`. Tests: - 6 new V2 tests: contact event surfaces for overlapping spheres; distant capsules produce no contacts; collider_for honored at first-sight spawn (verified via direct ColliderSet shape inspection); shape change after first-sight is ignored AND collider_for is called exactly once per entity; one-tick-delay semantics for contact events; no duplicate Started for a persistent overlap. - All 18 V1 tests pass unchanged (same trait, same constructors, same wire). Verification: 54 unit tests + 35 integration + 1 doctest pass under `--features rapier-cluster`. Vanilla 65 tests pass; vanilla `cargo tree` shows zero `rapier3d` references. Clippy silent both modes. Closes #118. Refs #8, #117. Co-Authored-By: Claude Opus 4.7 --- crates/arcane-infra/src/lib.rs | 5 +- crates/arcane-infra/src/rapier_cluster.rs | 657 ++++++++++++++++++++-- 2 files changed, 601 insertions(+), 61 deletions(-) diff --git a/crates/arcane-infra/src/lib.rs b/crates/arcane-infra/src/lib.rs index e5215df..9c7321c 100644 --- a/crates/arcane-infra/src/lib.rs +++ b/crates/arcane-infra/src/lib.rs @@ -46,4 +46,7 @@ pub use replication_channel_manager::ReplicationChannelManager; pub use rpc_handler::RpcHandler; #[cfg(feature = "rapier-cluster")] -pub use rapier_cluster::{RapierClusterSim, RapierConfig}; +pub use rapier_cluster::{ + ContactEvent, RapierClusterSim, RapierClusterSimulation, RapierClusterTickContext, + RapierColliderShape, RapierConfig, +}; diff --git a/crates/arcane-infra/src/rapier_cluster.rs b/crates/arcane-infra/src/rapier_cluster.rs index e263384..a58b3fb 100644 --- a/crates/arcane-infra/src/rapier_cluster.rs +++ b/crates/arcane-infra/src/rapier_cluster.rs @@ -14,9 +14,20 @@ //! Subsequent user writes are overwritten by Rapier's post-step output. //! - Despawn is driven by `pending_removals` — when an entity leaves the map, its //! rigid body and collider are removed from the Rapier world. -//! - Every entity is spawned with a single uniform sphere collider -//! ([`RapierConfig::default_body_radius`]). Per-entity collider shapes via the -//! `user_data` schema are a v2 follow-up. +//! - Default collider is a uniform sphere ([`RapierConfig::default_body_radius`]). +//! Implement [`RapierClusterSimulation`] and override `collider_for` to declare +//! per-entity shapes ([`RapierColliderShape::Ball`] / `Capsule` / `Cuboid`). +//! Shape is fixed at first-sight spawn; later `collider_for` returns are ignored +//! for already-spawned entities (despawn-and-respawn to change shape). +//! +//! # Contact events +//! +//! [`RapierClusterSimulation::on_tick`] receives a [`RapierClusterTickContext`] +//! carrying `contact_events: &[ContactEvent]` — collisions detected during the +//! **previous** tick's physics step, with both entity ids and a `started` flag. +//! One-tick delay is by design: user logic runs first to set intent, then physics +//! produces output for next tick. Same-tick post-physics reactivity is a future +//! follow-up. //! //! # Substepping //! @@ -47,6 +58,10 @@ //! // let user_sim: Arc = Arc::new(MyGameLogic::new()); //! // let physics = Arc::new(RapierClusterSim::new(Some(user_sim), RapierConfig::default())); //! +//! // Or: implement RapierClusterSimulation for per-entity shapes + contact events: +//! // let game: Arc = Arc::new(MyGame::new()); +//! // let physics = Arc::new(RapierClusterSim::with_rapier_sim(game, RapierConfig::default())); +//! //! // Pass `Some(physics)` as the simulation arg to `run_cluster_loop`. //! ``` @@ -88,6 +103,95 @@ impl Default for RapierConfig { } } +/// The collider shape used for an entity's rigid body. Resolved at first-sight +/// spawn via [`RapierClusterSimulation::collider_for`]; subsequent calls are +/// ignored for already-spawned entities. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum RapierColliderShape { + /// Sphere with the given radius. + Ball(f32), + /// Capsule oriented along Y, defined by the half-length of the cylindrical + /// section (excluding the hemispherical caps) and the radius. + Capsule { + /// Half-length of the cylindrical mid-section along Y. + half_height: f32, + /// Radius of the capsule (also the radius of the hemispherical caps). + radius: f32, + }, + /// Axis-aligned box defined by half-extents along each axis (X, Y, Z). + Cuboid([f32; 3]), +} + +impl Default for RapierColliderShape { + fn default() -> Self { + RapierColliderShape::Ball(DEFAULT_BODY_RADIUS) + } +} + +fn build_collider(shape: RapierColliderShape) -> Collider { + let builder = match shape { + RapierColliderShape::Ball(radius) => ColliderBuilder::ball(radius), + RapierColliderShape::Capsule { + half_height, + radius, + } => ColliderBuilder::capsule_y(half_height, radius), + RapierColliderShape::Cuboid(he) => ColliderBuilder::cuboid(he[0], he[1], he[2]), + }; + builder.active_events(ActiveEvents::COLLISION_EVENTS).build() +} + +/// A collision detected during a Rapier step, mapped from Rapier's collider +/// handles back to entity ids. Surfaced to [`RapierClusterSimulation::on_tick`] +/// via [`RapierClusterTickContext::contact_events`]. +/// +/// `started == true` signals the contact started this tick; `false` signals it +/// stopped. Stop events also fire when one of the colliders is destroyed (e.g., +/// despawn / pending_removal), in which case the corresponding entity_id is the +/// one that was just removed. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ContactEvent { + pub entity_a: Uuid, + pub entity_b: Uuid, + pub started: bool, +} + +/// Tick context delivered to [`RapierClusterSimulation::on_tick`]. Mirrors +/// [`ClusterTickContext`] field-for-field plus Rapier-specific extensions. +pub struct RapierClusterTickContext<'a> { + pub cluster_id: Uuid, + pub tick: u64, + pub dt_seconds: f64, + pub entities: &'a mut HashMap, + pub pending_removals: &'a mut Vec, + pub game_actions: &'a [arcane_core::cluster_simulation::GameAction], + /// Contact events from the **previous** tick's physics step. One-tick delay + /// by design — see module-level "Contact events" docs. + pub contact_events: &'a [ContactEvent], +} + +/// Rapier-aware sibling of [`ClusterSimulation`]. Implement this trait and pass +/// the impl to [`RapierClusterSim::with_rapier_sim`] when you need per-entity +/// collider shapes or contact events. For pure velocity-integration with no +/// shape customization, the plain [`ClusterSimulation`] path remains valid. +pub trait RapierClusterSimulation: Send + Sync { + /// Per-tick hook. Same lifecycle as [`ClusterSimulation::on_tick`] — + /// runs after client updates and before the Rapier physics step. + fn on_tick(&self, ctx: &mut RapierClusterTickContext<'_>); + + /// Declare the collider shape for an entity at first-sight spawn. Default + /// returns `Ball(config.default_body_radius)` — same as the V1 path. + /// Override to vary shape per entity (read `entry.user_data` for class + /// info, etc.). Called exactly once per entity, when its body is first + /// created in the Rapier world. + fn collider_for( + &self, + _entry: &EntityStateEntry, + config: &RapierConfig, + ) -> RapierColliderShape { + RapierColliderShape::Ball(config.default_body_radius) + } +} + struct RapierState { bodies: RigidBodySet, colliders: ColliderSet, @@ -100,13 +204,62 @@ struct RapierState { multibody_joints: MultibodyJointSet, ccd_solver: CCDSolver, handles: HashMap, + /// Reverse map for translating Rapier's `CollisionEvent` collider handles + /// back into entity ids. Maintained alongside `handles` on every spawn / + /// despawn. + collider_to_entity: HashMap, accumulator: f32, gravity: Vector, - default_body_radius: f32, + /// Contact events accumulated during the most recent `step_with_accumulator` + /// call. The wrapper drains these at the top of the next tick and surfaces + /// them to the user via [`RapierClusterTickContext::contact_events`]. + pending_contact_events: Vec, +} + +/// Internal `EventHandler` impl that records collisions into a `Mutex`. +/// We only use `handle_collision_event` in v2; force events are out of scope. +struct CollisionRecorder { + events: Mutex>, +} + +impl CollisionRecorder { + fn new() -> Self { + Self { + events: Mutex::new(Vec::new()), + } + } + + fn drain(self) -> Vec { + self.events.into_inner().unwrap_or_default() + } +} + +impl EventHandler for CollisionRecorder { + fn handle_collision_event( + &self, + _bodies: &RigidBodySet, + _colliders: &ColliderSet, + event: CollisionEvent, + _contact_pair: Option<&ContactPair>, + ) { + if let Ok(mut buf) = self.events.lock() { + buf.push(event); + } + } + + fn handle_contact_force_event( + &self, + _dt: Real, + _bodies: &RigidBodySet, + _colliders: &ColliderSet, + _contact_pair: &ContactPair, + _total_force_magnitude: Real, + ) { + } } impl RapierState { - fn new(config: &RapierConfig) -> Self { + fn new(gravity: Vector) -> Self { Self { bodies: RigidBodySet::new(), colliders: ColliderSet::new(), @@ -122,13 +275,19 @@ impl RapierState { multibody_joints: MultibodyJointSet::new(), ccd_solver: CCDSolver::new(), handles: HashMap::new(), + collider_to_entity: HashMap::new(), accumulator: 0.0, - gravity: Vector::new(config.gravity[0], config.gravity[1], config.gravity[2]), - default_body_radius: config.default_body_radius, + gravity, + pending_contact_events: Vec::new(), } } - fn spawn(&mut self, entity_id: Uuid, entry: &EntityStateEntry) -> RigidBodyHandle { + fn spawn( + &mut self, + entity_id: Uuid, + entry: &EntityStateEntry, + shape: RapierColliderShape, + ) -> RigidBodyHandle { let body = RigidBodyBuilder::dynamic() .translation(Vector::new( entry.position.x as f32, @@ -141,54 +300,53 @@ impl RapierState { entry.velocity.z as f32, )) .build(); - let handle = self.bodies.insert(body); - let collider = ColliderBuilder::ball(self.default_body_radius).build(); - self.colliders - .insert_with_parent(collider, handle, &mut self.bodies); - self.handles.insert(entity_id, handle); - handle + let body_handle = self.bodies.insert(body); + let collider = build_collider(shape); + let collider_handle = + self.colliders + .insert_with_parent(collider, body_handle, &mut self.bodies); + self.handles.insert(entity_id, body_handle); + self.collider_to_entity.insert(collider_handle, entity_id); + body_handle } - fn despawn(&mut self, entity_id: Uuid) { - if let Some(handle) = self.handles.remove(&entity_id) { - self.bodies.remove( - handle, - &mut self.islands, - &mut self.colliders, - &mut self.impulse_joints, - &mut self.multibody_joints, - true, - ); - } + fn set_linvel(&mut self, entity_id: Uuid, vel_x: f64, vel_y: f64, vel_z: f64) { + let Some(&handle) = self.handles.get(&entity_id) else { + return; + }; + let Some(body) = self.bodies.get_mut(handle) else { + return; + }; + body.set_linvel(Vector::new(vel_x as f32, vel_y as f32, vel_z as f32), true); } - fn sync_inputs(&mut self, entities: &HashMap, skip: &[Uuid]) { - for (id, entry) in entities.iter() { - if skip.contains(id) { - continue; - } - match self.handles.get(id).copied() { - None => { - self.spawn(*id, entry); - } - Some(handle) => { - if let Some(body) = self.bodies.get_mut(handle) { - body.set_linvel( - Vector::new( - entry.velocity.x as f32, - entry.velocity.y as f32, - entry.velocity.z as f32, - ), - true, - ); - } - } + fn despawn(&mut self, entity_id: Uuid) { + let Some(body_handle) = self.handles.remove(&entity_id) else { + return; + }; + // Drop reverse-map entries for any colliders attached to this body. + if let Some(body) = self.bodies.get(body_handle) { + let coll_handles: Vec = body.colliders().to_vec(); + for ch in coll_handles { + self.collider_to_entity.remove(&ch); } } + self.bodies.remove( + body_handle, + &mut self.islands, + &mut self.colliders, + &mut self.impulse_joints, + &mut self.multibody_joints, + true, + ); } fn step_with_accumulator(&mut self, dt_seconds: f32) { self.accumulator += dt_seconds; + if self.accumulator < FIXED_PHYSICS_DT { + return; + } + let recorder = CollisionRecorder::new(); while self.accumulator >= FIXED_PHYSICS_DT { self.physics_pipeline.step( self.gravity, @@ -202,10 +360,33 @@ impl RapierState { &mut self.multibody_joints, &mut self.ccd_solver, &(), - &(), + &recorder, ); self.accumulator -= FIXED_PHYSICS_DT; } + // Translate Rapier collision events into entity-keyed contact events. + // Skip events whose collider handles aren't in our map (e.g., colliders + // belonging to bodies despawned earlier this tick — Rapier may emit a + // Stopped event for such cases). + let raw = recorder.drain(); + self.pending_contact_events.reserve(raw.len()); + for event in raw { + let (h1, h2, started) = match event { + CollisionEvent::Started(a, b, _) => (a, b, true), + CollisionEvent::Stopped(a, b, _) => (a, b, false), + }; + let (Some(&entity_a), Some(&entity_b)) = ( + self.collider_to_entity.get(&h1), + self.collider_to_entity.get(&h2), + ) else { + continue; + }; + self.pending_contact_events.push(ContactEvent { + entity_a, + entity_b, + started, + }); + } } fn sync_outputs(&self, entities: &mut HashMap, skip: &[Uuid]) { @@ -243,52 +424,138 @@ impl RapierState { } } +/// User-logic backend wrapped by [`RapierClusterSim`]. +enum Backend { + /// No user-side logic — Rapier just integrates whatever `entity.velocity` + /// is on the wire. Useful for the "background simulator" use case where + /// clients seed velocity and the server just keeps entities moving. + None, + /// Standard `ClusterSimulation` user (V1 path). User mutates `entity.velocity` + /// in their `on_tick`; default sphere collider on every spawn. + Cluster(Arc), + /// Rapier-aware user (V2 path). User gets contact events and per-entity + /// shape declarations. + Rapier(Arc), +} + /// A [`ClusterSimulation`] that runs the user's logic, then a Rapier physics step. /// -/// Wrap your `ClusterSimulation` in this and pass it to `run_cluster_loop`. -/// The user's `on_tick` runs first (mutating velocities, processing actions, -/// pushing to `pending_removals`); the Rapier step then advances poses and writes -/// the results back into the entity map for replication. +/// Three constructor flavors: +/// +/// - [`RapierClusterSim::new`] — wraps an `Option>`. +/// `None` means pure Rapier integration with no game logic. Default sphere +/// collider on every entity (radius from [`RapierConfig::default_body_radius`]). +/// - [`RapierClusterSim::with_default_config`] — same as `new` with default config. +/// - [`RapierClusterSim::with_rapier_sim`] — wraps an +/// `Arc`. Unlocks per-entity collider shapes +/// (`collider_for`) and surfaces contact events to the user via +/// [`RapierClusterTickContext`]. pub struct RapierClusterSim { - user_sim: Option>, + backend: Backend, + config: RapierConfig, state: Mutex, } impl RapierClusterSim { pub fn new(user_sim: Option>, config: RapierConfig) -> Self { + let gravity = Vector::new(config.gravity[0], config.gravity[1], config.gravity[2]); + let backend = match user_sim { + Some(s) => Backend::Cluster(s), + None => Backend::None, + }; Self { - user_sim, - state: Mutex::new(RapierState::new(&config)), + backend, + config, + state: Mutex::new(RapierState::new(gravity)), } } pub fn with_default_config(user_sim: Option>) -> Self { Self::new(user_sim, RapierConfig::default()) } + + /// V2 constructor — accepts a [`RapierClusterSimulation`]. Use this when you + /// want per-entity collider shapes or contact events. + pub fn with_rapier_sim( + rapier_sim: Arc, + config: RapierConfig, + ) -> Self { + let gravity = Vector::new(config.gravity[0], config.gravity[1], config.gravity[2]); + Self { + backend: Backend::Rapier(rapier_sim), + config, + state: Mutex::new(RapierState::new(gravity)), + } + } + + fn shape_for(&self, entry: &EntityStateEntry) -> RapierColliderShape { + match &self.backend { + Backend::Rapier(sim) => sim.collider_for(entry, &self.config), + _ => RapierColliderShape::Ball(self.config.default_body_radius), + } + } } impl ClusterSimulation for RapierClusterSim { fn on_tick(&self, ctx: &mut ClusterTickContext<'_>) { - if let Some(user_sim) = &self.user_sim { - user_sim.on_tick(ctx); + // Pull contact events from the previous step. The Rapier-aware backend + // exposes them to the user via the extended context. (We take ownership + // here rather than borrow so the user's on_tick can run without holding + // the state lock.) + let prev_contacts = { + let mut state = self.state.lock().expect("rapier state lock"); + std::mem::take(&mut state.pending_contact_events) + }; + + match &self.backend { + Backend::None => {} + Backend::Cluster(sim) => sim.on_tick(ctx), + Backend::Rapier(sim) => { + let mut rapier_ctx = RapierClusterTickContext { + cluster_id: ctx.cluster_id, + tick: ctx.tick, + dt_seconds: ctx.dt_seconds, + entities: ctx.entities, + pending_removals: ctx.pending_removals, + game_actions: ctx.game_actions, + contact_events: &prev_contacts, + }; + sim.on_tick(&mut rapier_ctx); + } } let mut state = self.state.lock().expect("rapier state lock"); // Entities the user has flagged for removal this tick. The cluster runner // will drop them from the entity map *after* on_tick returns, but for our - // purposes they are already gone — no input sync, no output sync, no body. - // Pass the slice directly to avoid a per-tick HashSet allocation in the - // common (empty) case; n is small enough that linear scan beats hashing. + // purposes they are already gone — no spawn, no sync, no body. Linear + // scan over the slice; in steady state pending_removals is empty so the + // contains() calls are cheap. let removed: &[Uuid] = ctx.pending_removals.as_slice(); for &id in removed { state.despawn(id); } state.despawn_missing(ctx.entities); - state.sync_inputs(ctx.entities, removed); + // Spawn new entities and sync velocity intent for existing ones. + // Done outside RapierState so the wrapper can call self.shape_for() to + // resolve per-entity collider shape via the active backend. + for (id, entry) in ctx.entities.iter() { + if removed.contains(id) { + continue; + } + if state.handles.contains_key(id) { + state.set_linvel(*id, entry.velocity.x, entry.velocity.y, entry.velocity.z); + } else { + let shape = self.shape_for(entry); + state.spawn(*id, entry, shape); + } + } + state.step_with_accumulator(ctx.dt_seconds as f32); state.sync_outputs(ctx.entities, removed); + // Contact events from this step now live in state.pending_contact_events + // for next tick's user_sim to read. } } @@ -859,4 +1126,274 @@ mod tests { assert!((direct.y - via_handoff.y).abs() < 0.05); assert!((direct.z - via_handoff.z).abs() < 0.05); } + + // ─── V2: contact events + per-entity colliders ────────────────────────────── + + /// Test helper: records every contact event the wrapper surfaces. + struct ContactRecorder { + events: Mutex>, + shape: RapierColliderShape, + } + + impl ContactRecorder { + fn new(shape: RapierColliderShape) -> Arc { + Arc::new(Self { + events: Mutex::new(Vec::new()), + shape, + }) + } + fn snapshot(&self) -> Vec { + self.events.lock().unwrap().clone() + } + } + + impl RapierClusterSimulation for ContactRecorder { + fn on_tick(&self, ctx: &mut RapierClusterTickContext<'_>) { + self.events + .lock() + .unwrap() + .extend_from_slice(ctx.contact_events); + } + fn collider_for( + &self, + _entry: &EntityStateEntry, + _config: &RapierConfig, + ) -> RapierColliderShape { + self.shape + } + } + + /// Returns whether (a, b) appears in the recorded events as a Started + /// event in either ordering. + fn started_pair_present(events: &[ContactEvent], a: Uuid, b: Uuid) -> bool { + events.iter().any(|e| { + e.started + && ((e.entity_a == a && e.entity_b == b) || (e.entity_a == b && e.entity_b == a)) + }) + } + + fn count_started_for_pair(events: &[ContactEvent], a: Uuid, b: Uuid) -> usize { + events + .iter() + .filter(|e| { + e.started + && ((e.entity_a == a && e.entity_b == b) + || (e.entity_a == b && e.entity_b == a)) + }) + .count() + } + + #[test] + fn contact_event_surfaces_for_overlapping_spheres() { + let recorder = ContactRecorder::new(RapierColliderShape::Ball(0.5)); + let sim = RapierClusterSim::with_rapier_sim( + recorder.clone() as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + // Centers 0.4 apart with radius 0.5 each → significant overlap. + entities.insert(a, mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert(b, mk_entry(b, Vec3::new(0.4, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + + // Tick 1 spawns and steps; contact emitted post-step. Tick 2 surfaces it. + step_n(&sim, &mut entities, 2, CLUSTER_DT); + + let events = recorder.snapshot(); + assert!( + started_pair_present(&events, a, b), + "expected Started event for ({a}, {b}); got {events:?}" + ); + } + + #[test] + fn distant_capsules_produce_no_contacts() { + let recorder = ContactRecorder::new(RapierColliderShape::Capsule { + half_height: 1.0, + radius: 0.4, + }); + let sim = RapierClusterSim::with_rapier_sim( + recorder.clone() as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + // 100 units apart — well outside any collider radius. + entities.insert(a, mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert(b, mk_entry(b, Vec3::new(100.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + + step_n(&sim, &mut entities, 5, CLUSTER_DT); + + let events = recorder.snapshot(); + assert!(events.is_empty(), "expected no contacts; got {events:?}"); + } + + #[test] + fn collider_for_is_honored_at_first_sight() { + // Verify the collider attached to the spawned body matches the shape + // returned by collider_for, by directly inspecting the ColliderSet. + let recorder = ContactRecorder::new(RapierColliderShape::Cuboid([0.7, 0.3, 0.5])); + let sim = RapierClusterSim::with_rapier_sim( + recorder.clone() as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + step_once(&sim, &mut entities, 1, CLUSTER_DT); + + let state = sim.state.lock().unwrap(); + let body_handle = *state.handles.get(&id).unwrap(); + let body = state.bodies.get(body_handle).unwrap(); + let coll_handle = body.colliders().first().copied().unwrap(); + let collider = state.colliders.get(coll_handle).unwrap(); + let cuboid = collider + .shape() + .as_cuboid() + .expect("collider should be a Cuboid"); + let he = cuboid.half_extents; + assert!((he.x - 0.7).abs() < 1e-6, "half_extents.x = {}", he.x); + assert!((he.y - 0.3).abs() < 1e-6, "half_extents.y = {}", he.y); + assert!((he.z - 0.5).abs() < 1e-6, "half_extents.z = {}", he.z); + } + + #[test] + fn shape_change_after_first_sight_is_ignored() { + // collider_for must be called exactly once per entity (at first-sight + // spawn); never again. Documented contract: changing the return value + // afterwards has no effect on already-spawned bodies. We verify two + // things: (a) collider_for gets exactly one call, and (b) the resulting + // collider matches the first call's shape, even though later calls + // would return a different shape. + struct ShiftingShape { + call_count: AtomicU64, + } + impl RapierClusterSimulation for ShiftingShape { + fn on_tick(&self, _ctx: &mut RapierClusterTickContext<'_>) {} + fn collider_for( + &self, + _entry: &EntityStateEntry, + _config: &RapierConfig, + ) -> RapierColliderShape { + let n = self.call_count.fetch_add(1, Ordering::SeqCst); + if n == 0 { + RapierColliderShape::Ball(0.7) + } else { + RapierColliderShape::Cuboid([1.0, 1.0, 1.0]) + } + } + } + let sim_inner = Arc::new(ShiftingShape { + call_count: AtomicU64::new(0), + }); + let sim = RapierClusterSim::with_rapier_sim( + sim_inner.clone() as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + step_n(&sim, &mut entities, 5, CLUSTER_DT); + + assert_eq!( + sim_inner.call_count.load(Ordering::SeqCst), + 1, + "collider_for must be called exactly once per entity" + ); + + let state = sim.state.lock().unwrap(); + let body_handle = *state.handles.get(&id).unwrap(); + let body = state.bodies.get(body_handle).unwrap(); + let coll_handle = body.colliders().first().copied().unwrap(); + let collider = state.colliders.get(coll_handle).unwrap(); + let ball = collider + .shape() + .as_ball() + .expect("collider should still be the original Ball from the first call"); + assert!((ball.radius - 0.7).abs() < 1e-6); + } + + #[test] + fn contact_events_one_tick_delay_to_user() { + // Tick 1: spawn overlapping → Rapier step → contact recorded internally. + // User on_tick during tick 1 sees `contact_events: &[]`. + // Tick 2: User on_tick sees the contact. + struct PerTickSnapshot { + per_tick: Mutex>, + shape: RapierColliderShape, + } + impl RapierClusterSimulation for PerTickSnapshot { + fn on_tick(&self, ctx: &mut RapierClusterTickContext<'_>) { + self.per_tick + .lock() + .unwrap() + .push((ctx.tick, ctx.contact_events.len())); + } + fn collider_for( + &self, + _entry: &EntityStateEntry, + _config: &RapierConfig, + ) -> RapierColliderShape { + self.shape + } + } + let recorder = Arc::new(PerTickSnapshot { + per_tick: Mutex::new(Vec::new()), + shape: RapierColliderShape::Ball(0.5), + }); + let sim = RapierClusterSim::with_rapier_sim( + recorder.clone() as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + entities.insert(a, mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert(b, mk_entry(b, Vec3::new(0.4, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + + step_n(&sim, &mut entities, 3, CLUSTER_DT); + + let snapshots = recorder.per_tick.lock().unwrap().clone(); + // Tick 1 must see 0 contacts (nothing has stepped yet from this sim's + // perspective; pending_contact_events starts empty). + assert_eq!(snapshots[0].0, 1); + assert_eq!(snapshots[0].1, 0, "tick 1 should have no contact events yet"); + // Tick 2 must see at least one contact (the Started from tick 1's step). + assert_eq!(snapshots[1].0, 2); + assert!( + snapshots[1].1 >= 1, + "tick 2 should surface the contact from tick 1's step; got {} events", + snapshots[1].1 + ); + } + + #[test] + fn no_duplicate_started_event_for_persistent_overlap() { + // Started is edge-triggered: it should fire once per contact, not every + // tick the contact persists. Place two bodies overlapping with zero + // velocity and zero gravity; they remain in contact for many ticks but + // only one Started event surfaces. + let recorder = ContactRecorder::new(RapierColliderShape::Ball(0.5)); + let sim = RapierClusterSim::with_rapier_sim( + recorder.clone() as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + entities.insert(a, mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert(b, mk_entry(b, Vec3::new(0.6, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + + step_n(&sim, &mut entities, 20, CLUSTER_DT); + + let events = recorder.snapshot(); + assert_eq!( + count_started_for_pair(&events, a, b), + 1, + "Started should fire exactly once for a persistent contact; got events {:?}", + events + ); + } } From 6a9c4fe6ef603dcaf74aabd2e8923999bb2ea1d6 Mon Sep 17 00:00:00 2001 From: martinjms Date: Sun, 3 May 2026 12:38:22 +0300 Subject: [PATCH 3/5] refactor(rapier-cluster): review-driven polish + comprehensive contract tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synthesizes findings from the simplify skill (3 parallel review agents: reuse / quality / efficiency) and a security-review pass, plus an architectural pass on SemVer stability. Security review found zero HIGH/MEDIUM vulnerabilities. Adds 14 tests so every module-doc claim is backed by a test that would fail if the claim broke. Code polish: - New `to_rapier(Vec3) -> Vector` and `from_rapier(Vector) -> Vec3` helpers replacing five sites of triplet `.x as f32, .y as f32, .z as f32` casts. - `RapierState::set_linvel` now takes `Vec3` instead of three `f64`s. - Deleted unused `impl Default for RapierColliderShape` (closed a drift door vs `RapierConfig::default_body_radius`). - `CollisionRecorder` propagates Mutex poison via `expect()` instead of silently dropping events on poison — surfaces panics that happen mid-step. - Per-tick `pending_removals` lookup now uses a `HashSet` (was O(N×M) linear scan over a slice when entities × removals was non-trivial). - Extracted `ClusterEnv::from_env()` helper into `cluster_runner`; both `arcane_cluster` and `arcane_rapier_cluster` binaries use it (was a verbatim duplicate of env-parsing across the two binaries). - Stripped `v1`/`v2` release-stage labels from doc comments per project policy; trimmed several comments that restated the next line of code. SemVer stability: - `#[non_exhaustive]` on `RapierColliderShape`, `ContactEvent`, `RapierClusterTickContext`, `RapierConfig`. Future additions (e.g. `Cylinder` shape, `impulse_magnitude` on events, query handles in the context) won't be breaking changes. New tests (every one corresponds to a documented contract that was previously unverified): T1 stopped_event_surfaces_when_bodies_separate T2 despawn_during_contact_does_not_surface_stopped_event (pins the no-Stopped-on-despawn behavior; partners detect via the entity map) T3 default_path_collider_is_a_ball_with_config_radius (V1 default shape directly inspected; previously only Cuboid was) T4 capsule_collider_is_honored_at_first_sight (capsule shape inspected) T5 multi_substep_in_one_cluster_tick (dt=0.1 → 6 substeps, position ~0.1) T6 slow_dt_accumulates_until_substep_fires (dt=0.005, fires after ~3-4 ticks, position converges to dt_total*v) T7 contact_resolution_applies_impulse_to_partner (B gets pushed when A collides with it — Rapier responds, doesn't just detect) T8 collider_for_invoked_freshly_on_respawn (despawn-respawn-same-uuid triggers a fresh shape decision) T9 rapier_ctx_propagates_game_actions_tick_and_dt (V2 parallel of the V1 context-propagation test) T10 rapier_user_can_request_removal_via_pending_removals (V2 parallel of the V1 removal test) T11 mixed_shape_ball_vs_cuboid_produces_contact (cross-shape collision now exercised; all prior contact tests paired same-shape) T12 nondefault_gravity_honored_on_arbitrary_axis (gravity isn't hardcoded to -Y somewhere) T13 contact_events_do_not_carry_across_handoff (cluster B's first tick sees ctx.contact_events == &[], not cluster A's events) T14 capsule_axis_is_y (segment endpoints at (0, ±half_height, 0)) Verification: 68 lib tests (was 54) + 35 integration + 1 doctest pass under `--features rapier-cluster`. Vanilla 65 unchanged. Clippy silent both modes. Vanilla dep tree still has zero `rapier3d`. Refs #117, #118, #8. Co-Authored-By: Claude Opus 4.7 --- crates/arcane-infra/src/bin/arcane_cluster.rs | 37 +- .../src/bin/arcane_rapier_cluster.rs | 36 +- crates/arcane-infra/src/cluster_runner.rs | 43 ++ crates/arcane-infra/src/rapier_cluster.rs | 723 ++++++++++++++++-- 4 files changed, 700 insertions(+), 139 deletions(-) diff --git a/crates/arcane-infra/src/bin/arcane_cluster.rs b/crates/arcane-infra/src/bin/arcane_cluster.rs index b8c1ea9..c258cc9 100644 --- a/crates/arcane-infra/src/bin/arcane_cluster.rs +++ b/crates/arcane-infra/src/bin/arcane_cluster.rs @@ -10,44 +10,22 @@ //! Example: //! CLUSTER_ID=550e8400-e29b-41d4-a716-446655440000 cargo run -p arcane-infra --bin arcane-cluster --features cluster-ws -use std::env; use std::sync::Arc; use arcane_core::ClusterSimulation; -use arcane_infra::cluster_runner; -use uuid::Uuid; -fn parse_uuids(s: &str) -> Vec { - s.split(',') - .map(|x| x.trim()) - .filter(|x| !x.is_empty()) - .filter_map(|x| Uuid::parse_str(x).ok()) - .collect() -} +#[cfg(feature = "cluster-ws")] +use arcane_infra::cluster_runner::{self, ClusterEnv}; fn main() -> Result<(), String> { - let cluster_id = - env::var("CLUSTER_ID").map_err(|_| "CLUSTER_ID env var required (UUID)".to_string())?; - let cluster_id = - Uuid::parse_str(&cluster_id).map_err(|e| format!("invalid CLUSTER_ID: {}", e))?; - - let redis_url = env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); - let neighbor_ids = env::var("NEIGHBOR_IDS") - .map(|s| parse_uuids(&s)) - .unwrap_or_default(); - - let ws_port: u16 = env::var("CLUSTER_WS_PORT") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(8080); - #[cfg(feature = "cluster-ws")] { + let env = ClusterEnv::from_env()?; cluster_runner::run_cluster_loop( - cluster_id, - redis_url, - neighbor_ids, - ws_port, + env.cluster_id, + env.redis_url, + env.neighbor_ids, + env.ws_port, |_| vec![], // no demo entities; use arcane_cluster_demo from arcane-demo for that Option::>::None, ) @@ -55,7 +33,6 @@ fn main() -> Result<(), String> { #[cfg(not(feature = "cluster-ws"))] { - let _ = (cluster_id, redis_url, neighbor_ids, ws_port); Err("cluster-ws feature required to run the cluster binary".to_string()) } } diff --git a/crates/arcane-infra/src/bin/arcane_rapier_cluster.rs b/crates/arcane-infra/src/bin/arcane_rapier_cluster.rs index 3b37c00..68e5542 100644 --- a/crates/arcane-infra/src/bin/arcane_rapier_cluster.rs +++ b/crates/arcane-infra/src/bin/arcane_rapier_cluster.rs @@ -12,46 +12,24 @@ //! NEIGHBOR_IDS — optional; comma-separated UUIDs of neighbor clusters. //! CLUSTER_WS_PORT — optional; default 8080. -use std::env; use std::sync::Arc; use arcane_core::ClusterSimulation; -use arcane_infra::{cluster_runner, RapierClusterSim, RapierConfig}; -use uuid::Uuid; - -fn parse_uuids(s: &str) -> Vec { - s.split(',') - .map(|x| x.trim()) - .filter(|x| !x.is_empty()) - .filter_map(|x| Uuid::parse_str(x).ok()) - .collect() -} +use arcane_infra::cluster_runner::{self, ClusterEnv}; +use arcane_infra::{RapierClusterSim, RapierConfig}; fn main() -> Result<(), String> { - let cluster_id = - env::var("CLUSTER_ID").map_err(|_| "CLUSTER_ID env var required (UUID)".to_string())?; - let cluster_id = - Uuid::parse_str(&cluster_id).map_err(|e| format!("invalid CLUSTER_ID: {}", e))?; - - let redis_url = env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); - let neighbor_ids = env::var("NEIGHBOR_IDS") - .map(|s| parse_uuids(&s)) - .unwrap_or_default(); - - let ws_port: u16 = env::var("CLUSTER_WS_PORT") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(8080); + let env = ClusterEnv::from_env()?; let user_sim: Option> = None; let rapier_sim: Arc = Arc::new(RapierClusterSim::new(user_sim, RapierConfig::default())); cluster_runner::run_cluster_loop( - cluster_id, - redis_url, - neighbor_ids, - ws_port, + env.cluster_id, + env.redis_url, + env.neighbor_ids, + env.ws_port, |_| vec![], Some(rapier_sim), ) diff --git a/crates/arcane-infra/src/cluster_runner.rs b/crates/arcane-infra/src/cluster_runner.rs index 3fc030a..ecb353d 100644 --- a/crates/arcane-infra/src/cluster_runner.rs +++ b/crates/arcane-infra/src/cluster_runner.rs @@ -31,6 +31,49 @@ const LOG_EVERY_TICKS: u64 = 100; /// Log parseable server stats every N ticks (for benchmark: entities, clusters, tick_ms). const LOG_STATS_EVERY_TICKS: u64 = 40; +/// Cluster-binary environment configuration (CLUSTER_ID, REDIS_URL, +/// NEIGHBOR_IDS, CLUSTER_WS_PORT). Shared by every cluster-binary entry point +/// so the env contract stays in one place. +#[derive(Clone, Debug)] +pub struct ClusterEnv { + pub cluster_id: Uuid, + pub redis_url: String, + pub neighbor_ids: Vec, + pub ws_port: u16, +} + +impl ClusterEnv { + /// Read the standard cluster env vars. `CLUSTER_ID` is required; the rest + /// have defaults (Redis at `127.0.0.1:6379`, no neighbors, WS port `8080`). + pub fn from_env() -> Result { + let cluster_id = std::env::var("CLUSTER_ID") + .map_err(|_| "CLUSTER_ID env var required (UUID)".to_string())?; + let cluster_id = Uuid::parse_str(&cluster_id) + .map_err(|e| format!("invalid CLUSTER_ID: {}", e))?; + let redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + let neighbor_ids = std::env::var("NEIGHBOR_IDS") + .map(|s| { + s.split(',') + .map(|x| x.trim()) + .filter(|x| !x.is_empty()) + .filter_map(|x| Uuid::parse_str(x).ok()) + .collect() + }) + .unwrap_or_default(); + let ws_port: u16 = std::env::var("CLUSTER_WS_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(8080); + Ok(Self { + cluster_id, + redis_url, + neighbor_ids, + ws_port, + }) + } +} + fn merge_with_neighbor_latest( our_delta: EntityStateDelta, neighbor_latest: &HashMap>, diff --git a/crates/arcane-infra/src/rapier_cluster.rs b/crates/arcane-infra/src/rapier_cluster.rs index a58b3fb..c7e5bf9 100644 --- a/crates/arcane-infra/src/rapier_cluster.rs +++ b/crates/arcane-infra/src/rapier_cluster.rs @@ -65,7 +65,7 @@ //! // Pass `Some(physics)` as the simulation arg to `run_cluster_loop`. //! ``` -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use rapier3d::prelude::*; @@ -73,24 +73,39 @@ use uuid::Uuid; use arcane_core::cluster_simulation::{ClusterSimulation, ClusterTickContext}; use arcane_core::replication_channel::EntityStateEntry; +use arcane_core::Vec3; + +fn to_rapier(v: Vec3) -> Vector { + Vector::new(v.x as f32, v.y as f32, v.z as f32) +} + +fn from_rapier(v: Vector) -> Vec3 { + Vec3::new(v.x as f64, v.y as f64, v.z as f64) +} /// Fixed Rapier substep size. 1/60 s matches the standard physics rate. const FIXED_PHYSICS_DT: f32 = 1.0 / 60.0; -/// V1 default body shape — uniform sphere collider. Per-entity shapes via `user_data` -/// schema is a follow-up. +/// Default body radius applied when no per-entity shape is declared. Override +/// per entity by implementing [`RapierClusterSimulation::collider_for`]. const DEFAULT_BODY_RADIUS: f32 = 0.5; /// Configuration knobs for [`RapierClusterSim`]. +/// +/// `#[non_exhaustive]` so adding fields in future versions isn't a SemVer +/// break. Construct via `RapierConfig::default()` and the struct-update form, +/// e.g. `RapierConfig { gravity: [0.0, -9.81, 0.0], ..Default::default() }`. #[derive(Clone, Debug)] +#[non_exhaustive] pub struct RapierConfig { /// World gravity vector in m/s². Default is zero gravity (matches benchmark /// parity: today's benchmark cluster does pure velocity integration with no /// downward acceleration). Set to e.g. `[0.0, -9.81, 0.0]` for Earth gravity /// along -Y. pub gravity: [f32; 3], - /// Sphere collider radius applied to every spawned entity. v1 uses one shape - /// for all bodies; per-entity shapes are a follow-up. + /// Default sphere radius for entities whose collider shape isn't customized. + /// Used by the `ClusterSimulation` constructor and as the default for + /// [`RapierClusterSimulation::collider_for`]. pub default_body_radius: f32, } @@ -106,7 +121,11 @@ impl Default for RapierConfig { /// The collider shape used for an entity's rigid body. Resolved at first-sight /// spawn via [`RapierClusterSimulation::collider_for`]; subsequent calls are /// ignored for already-spawned entities. +/// +/// `#[non_exhaustive]` so adding shapes (e.g. `Cylinder`, `ConvexHull`) in +/// future versions isn't a SemVer break. #[derive(Clone, Copy, Debug, PartialEq)] +#[non_exhaustive] pub enum RapierColliderShape { /// Sphere with the given radius. Ball(f32), @@ -122,12 +141,6 @@ pub enum RapierColliderShape { Cuboid([f32; 3]), } -impl Default for RapierColliderShape { - fn default() -> Self { - RapierColliderShape::Ball(DEFAULT_BODY_RADIUS) - } -} - fn build_collider(shape: RapierColliderShape) -> Collider { let builder = match shape { RapierColliderShape::Ball(radius) => ColliderBuilder::ball(radius), @@ -144,11 +157,17 @@ fn build_collider(shape: RapierColliderShape) -> Collider { /// handles back to entity ids. Surfaced to [`RapierClusterSimulation::on_tick`] /// via [`RapierClusterTickContext::contact_events`]. /// -/// `started == true` signals the contact started this tick; `false` signals it -/// stopped. Stop events also fire when one of the colliders is destroyed (e.g., -/// despawn / pending_removal), in which case the corresponding entity_id is the -/// one that was just removed. +/// `started == true` signals the contact started this tick; `false` signals +/// it stopped because the bodies separated. **Despawn does not surface a +/// `Stopped` event to the contact partner** — when a body is removed (via +/// `pending_removals` or by vanishing from the entity map), its contacts +/// terminate silently and the partner detects the loss by observing the +/// despawn through the entity map. (Pinned by tests.) +/// +/// `#[non_exhaustive]` so adding fields (e.g. `impulse_magnitude`) in future +/// versions isn't a SemVer break. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] pub struct ContactEvent { pub entity_a: Uuid, pub entity_b: Uuid, @@ -157,6 +176,10 @@ pub struct ContactEvent { /// Tick context delivered to [`RapierClusterSimulation::on_tick`]. Mirrors /// [`ClusterTickContext`] field-for-field plus Rapier-specific extensions. +/// +/// `#[non_exhaustive]` so future fields (e.g. raycast/query handles, physics +/// command queues) aren't a SemVer break for downstream impls. +#[non_exhaustive] pub struct RapierClusterTickContext<'a> { pub cluster_id: Uuid, pub tick: u64, @@ -179,10 +202,9 @@ pub trait RapierClusterSimulation: Send + Sync { fn on_tick(&self, ctx: &mut RapierClusterTickContext<'_>); /// Declare the collider shape for an entity at first-sight spawn. Default - /// returns `Ball(config.default_body_radius)` — same as the V1 path. - /// Override to vary shape per entity (read `entry.user_data` for class - /// info, etc.). Called exactly once per entity, when its body is first - /// created in the Rapier world. + /// returns `Ball(config.default_body_radius)`. Override to vary shape per + /// entity (read `entry.user_data` for class info, etc.). Called exactly + /// once per entity, when its body is first created in the Rapier world. fn collider_for( &self, _entry: &EntityStateEntry, @@ -217,7 +239,8 @@ struct RapierState { } /// Internal `EventHandler` impl that records collisions into a `Mutex`. -/// We only use `handle_collision_event` in v2; force events are out of scope. +/// Only `handle_collision_event` is wired up; contact-force events are out of +/// scope until impulses/forces are exposed to user code. struct CollisionRecorder { events: Mutex>, } @@ -230,7 +253,9 @@ impl CollisionRecorder { } fn drain(self) -> Vec { - self.events.into_inner().unwrap_or_default() + self.events + .into_inner() + .expect("collision recorder mutex poisoned — a panic occurred during the physics step") } } @@ -242,9 +267,13 @@ impl EventHandler for CollisionRecorder { event: CollisionEvent, _contact_pair: Option<&ContactPair>, ) { - if let Ok(mut buf) = self.events.lock() { - buf.push(event); - } + // Mutex poisoning here means a panic happened mid-step inside the + // physics pipeline; surface it via panic-on-poison rather than dropping + // events silently. + self.events + .lock() + .expect("collision recorder mutex poisoned") + .push(event); } fn handle_contact_force_event( @@ -289,42 +318,31 @@ impl RapierState { shape: RapierColliderShape, ) -> RigidBodyHandle { let body = RigidBodyBuilder::dynamic() - .translation(Vector::new( - entry.position.x as f32, - entry.position.y as f32, - entry.position.z as f32, - )) - .linvel(Vector::new( - entry.velocity.x as f32, - entry.velocity.y as f32, - entry.velocity.z as f32, - )) + .translation(to_rapier(entry.position)) + .linvel(to_rapier(entry.velocity)) .build(); let body_handle = self.bodies.insert(body); - let collider = build_collider(shape); let collider_handle = self.colliders - .insert_with_parent(collider, body_handle, &mut self.bodies); + .insert_with_parent(build_collider(shape), body_handle, &mut self.bodies); self.handles.insert(entity_id, body_handle); self.collider_to_entity.insert(collider_handle, entity_id); body_handle } - fn set_linvel(&mut self, entity_id: Uuid, vel_x: f64, vel_y: f64, vel_z: f64) { + fn set_linvel(&mut self, entity_id: Uuid, vel: Vec3) { let Some(&handle) = self.handles.get(&entity_id) else { return; }; - let Some(body) = self.bodies.get_mut(handle) else { - return; - }; - body.set_linvel(Vector::new(vel_x as f32, vel_y as f32, vel_z as f32), true); + if let Some(body) = self.bodies.get_mut(handle) { + body.set_linvel(to_rapier(vel), true); + } } fn despawn(&mut self, entity_id: Uuid) { let Some(body_handle) = self.handles.remove(&entity_id) else { return; }; - // Drop reverse-map entries for any colliders attached to this body. if let Some(body) = self.bodies.get(body_handle) { let coll_handles: Vec = body.colliders().to_vec(); for ch in coll_handles { @@ -364,10 +382,8 @@ impl RapierState { ); self.accumulator -= FIXED_PHYSICS_DT; } - // Translate Rapier collision events into entity-keyed contact events. - // Skip events whose collider handles aren't in our map (e.g., colliders - // belonging to bodies despawned earlier this tick — Rapier may emit a - // Stopped event for such cases). + // Skip events whose collider handles aren't in our map — Rapier may + // emit Stopped for colliders we despawned earlier this tick. let raw = recorder.drain(); self.pending_contact_events.reserve(raw.len()); for event in raw { @@ -389,7 +405,7 @@ impl RapierState { } } - fn sync_outputs(&self, entities: &mut HashMap, skip: &[Uuid]) { + fn sync_outputs(&self, entities: &mut HashMap, skip: &HashSet) { for (id, entry) in entities.iter_mut() { if skip.contains(id) { continue; @@ -400,14 +416,8 @@ impl RapierState { let Some(body) = self.bodies.get(handle) else { continue; }; - let t = body.translation(); - let v = body.linvel(); - entry.position.x = t.x as f64; - entry.position.y = t.y as f64; - entry.position.z = t.z as f64; - entry.velocity.x = v.x as f64; - entry.velocity.y = v.y as f64; - entry.velocity.z = v.z as f64; + entry.position = from_rapier(body.translation()); + entry.velocity = from_rapier(body.linvel()); } } @@ -430,11 +440,11 @@ enum Backend { /// is on the wire. Useful for the "background simulator" use case where /// clients seed velocity and the server just keeps entities moving. None, - /// Standard `ClusterSimulation` user (V1 path). User mutates `entity.velocity` - /// in their `on_tick`; default sphere collider on every spawn. + /// Plain `ClusterSimulation` — user mutates `entity.velocity` in their + /// `on_tick`; default sphere collider on every spawn. Cluster(Arc), - /// Rapier-aware user (V2 path). User gets contact events and per-entity - /// shape declarations. + /// Rapier-aware user — receives contact events via the extended context and + /// can declare per-entity collider shapes. Rapier(Arc), } @@ -474,8 +484,9 @@ impl RapierClusterSim { Self::new(user_sim, RapierConfig::default()) } - /// V2 constructor — accepts a [`RapierClusterSimulation`]. Use this when you - /// want per-entity collider shapes or contact events. + /// Constructor for users who want per-entity collider shapes or contact + /// events. Accepts a [`RapierClusterSimulation`] in place of the plain + /// `ClusterSimulation` taken by [`Self::new`]. pub fn with_rapier_sim( rapier_sim: Arc, config: RapierConfig, @@ -498,10 +509,8 @@ impl RapierClusterSim { impl ClusterSimulation for RapierClusterSim { fn on_tick(&self, ctx: &mut ClusterTickContext<'_>) { - // Pull contact events from the previous step. The Rapier-aware backend - // exposes them to the user via the extended context. (We take ownership - // here rather than borrow so the user's on_tick can run without holding - // the state lock.) + // Take ownership of the previous step's contacts so the user's on_tick + // can run without holding the state lock. let prev_contacts = { let mut state = self.state.lock().expect("rapier state lock"); std::mem::take(&mut state.pending_contact_events) @@ -526,26 +535,21 @@ impl ClusterSimulation for RapierClusterSim { let mut state = self.state.lock().expect("rapier state lock"); - // Entities the user has flagged for removal this tick. The cluster runner - // will drop them from the entity map *after* on_tick returns, but for our - // purposes they are already gone — no spawn, no sync, no body. Linear - // scan over the slice; in steady state pending_removals is empty so the - // contains() calls are cheap. - let removed: &[Uuid] = ctx.pending_removals.as_slice(); - for &id in removed { + // The cluster runner drains pending_removals from the entity map AFTER + // on_tick returns; from our perspective those entities are already gone + // (no spawn, no sync, no body). + let removed: HashSet = ctx.pending_removals.iter().copied().collect(); + for &id in &removed { state.despawn(id); } state.despawn_missing(ctx.entities); - // Spawn new entities and sync velocity intent for existing ones. - // Done outside RapierState so the wrapper can call self.shape_for() to - // resolve per-entity collider shape via the active backend. for (id, entry) in ctx.entities.iter() { if removed.contains(id) { continue; } if state.handles.contains_key(id) { - state.set_linvel(*id, entry.velocity.x, entry.velocity.y, entry.velocity.z); + state.set_linvel(*id, entry.velocity); } else { let shape = self.shape_for(entry); state.spawn(*id, entry, shape); @@ -553,9 +557,7 @@ impl ClusterSimulation for RapierClusterSim { } state.step_with_accumulator(ctx.dt_seconds as f32); - state.sync_outputs(ctx.entities, removed); - // Contact events from this step now live in state.pending_contact_events - // for next tick's user_sim to read. + state.sync_outputs(ctx.entities, &removed); } } @@ -1127,7 +1129,7 @@ mod tests { assert!((direct.z - via_handoff.z).abs() < 0.05); } - // ─── V2: contact events + per-entity colliders ────────────────────────────── + // ─── contact events + per-entity colliders ────────────────────────────────── /// Test helper: records every contact event the wrapper surfaces. struct ContactRecorder { @@ -1396,4 +1398,565 @@ mod tests { events ); } + + // ─── extended V2 coverage: contract pinning + symmetry with V1 ────────────── + + fn stopped_pair_present(events: &[ContactEvent], a: Uuid, b: Uuid) -> bool { + events.iter().any(|e| { + !e.started + && ((e.entity_a == a && e.entity_b == b) || (e.entity_a == b && e.entity_b == a)) + }) + } + + /// Direct collider-shape inspection helper. Returns the rapier collider + /// attached to the body for this entity, or None if not spawned yet. + fn with_collider( + sim: &RapierClusterSim, + id: Uuid, + f: impl FnOnce(&Collider) -> R, + ) -> Option { + let state = sim.state.lock().unwrap(); + let body_handle = *state.handles.get(&id)?; + let body = state.bodies.get(body_handle)?; + let coll_handle = body.colliders().first().copied()?; + let coll = state.colliders.get(coll_handle)?; + Some(f(coll)) + } + + /// **T1**: a contact that *ends* (bodies move apart) surfaces a Stopped + /// event in the next tick's contact_events. Without this, gameplay code + /// that relies on "exited zone" / "broke contact" signals silently breaks. + #[test] + fn stopped_event_surfaces_when_bodies_separate() { + let recorder = ContactRecorder::new(RapierColliderShape::Ball(0.4)); + let sim = RapierClusterSim::with_rapier_sim( + recorder.clone() as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + // Start overlapping so Started fires immediately. + entities.insert(a, mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert(b, mk_entry(b, Vec3::new(0.6, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + step_n(&sim, &mut entities, 2, CLUSTER_DT); + assert!( + started_pair_present(&recorder.snapshot(), a, b), + "Started must surface before we test Stopped" + ); + + // Now drive B away fast enough that contact resolves to "stopped". + // 5 units/sec for 1 second → covers >> 2 × radius gap. + entities.get_mut(&b).unwrap().velocity = Vec3::new(5.0, 0.0, 0.0); + step_n(&sim, &mut entities, 30, CLUSTER_DT); + + let events = recorder.snapshot(); + assert!( + stopped_pair_present(&events, a, b), + "Stopped must surface when bodies separate; events were {:?}", + events + ); + } + + /// **T2**: when a body is despawned mid-contact, the contact partner does + /// **NOT** receive a Stopped event in the next tick. The contact + /// terminates silently because the despawned collider is dropped from the + /// reverse map before the post-step event drain. This is documented + /// behavior — partners detect the loss by observing the despawn through + /// the entity map (`pending_removals` or vanishing from `ctx.entities`). + #[test] + fn despawn_during_contact_does_not_surface_stopped_event() { + struct DespawnAOnTick3 { + events: Mutex>, + a_id: Uuid, + } + impl RapierClusterSimulation for DespawnAOnTick3 { + fn on_tick(&self, ctx: &mut RapierClusterTickContext<'_>) { + self.events + .lock() + .unwrap() + .extend_from_slice(ctx.contact_events); + if ctx.tick == 3 { + ctx.pending_removals.push(self.a_id); + } + } + fn collider_for( + &self, + _entry: &EntityStateEntry, + _config: &RapierConfig, + ) -> RapierColliderShape { + RapierColliderShape::Ball(0.4) + } + } + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + let recorder = Arc::new(DespawnAOnTick3 { + events: Mutex::new(Vec::new()), + a_id: a, + }); + let sim = RapierClusterSim::with_rapier_sim( + recorder.clone() as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + entities.insert(a, mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert(b, mk_entry(b, Vec3::new(0.5, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + + step_n(&sim, &mut entities, 6, CLUSTER_DT); + + let events = recorder.events.lock().unwrap().clone(); + assert!( + started_pair_present(&events, a, b), + "Started should have fired before the despawn" + ); + assert!( + !stopped_pair_present(&events, a, b), + "Despawn must NOT surface a Stopped event; events were {:?}", + events + ); + } + + /// **T3**: V1 default path produces an actual sphere collider with the + /// configured radius. Catches any regression where the default builder + /// silently swaps shape (would pass dynamics tests since pose round-trips + /// either way). + #[test] + fn default_path_collider_is_a_ball_with_config_radius() { + let config = RapierConfig { + default_body_radius: 0.42, + ..Default::default() + }; + let sim = RapierClusterSim::new(None, config); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + step_once(&sim, &mut entities, 1, CLUSTER_DT); + + let radius = with_collider(&sim, id, |c| c.shape().as_ball().map(|b| b.radius)) + .flatten() + .expect("collider should be a Ball"); + assert!((radius - 0.42).abs() < 1e-6, "ball radius = {}", radius); + } + + /// **T4**: capsule shape declared via `collider_for` produces an actual + /// capsule collider in Rapier — same direct-inspection invariant as T3, + /// but for the V2 path. + #[test] + fn capsule_collider_is_honored_at_first_sight() { + let recorder = ContactRecorder::new(RapierColliderShape::Capsule { + half_height: 0.9, + radius: 0.4, + }); + let sim = RapierClusterSim::with_rapier_sim( + recorder.clone() as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + step_once(&sim, &mut entities, 1, CLUSTER_DT); + + let capsule_radius = with_collider(&sim, id, |c| c.shape().as_capsule().map(|cap| cap.radius)) + .flatten() + .expect("collider should be a Capsule"); + assert!((capsule_radius - 0.4).abs() < 1e-6); + } + + /// **T5**: a single cluster tick whose `dt_seconds` exceeds `FIXED_PHYSICS_DT` + /// should run multiple Rapier substeps in one call to `on_tick`. With + /// `dt = 0.1 s` and `FIXED_PHYSICS_DT = 1/60 s`, the accumulator drains + /// 6 substeps; an entity at `vx = 1.0` should advance ≈ 0.1 m, not just + /// `1/60` m. Catches accumulator-truncation bugs that would silently + /// compress motion under slow cluster ticks. + #[test] + fn multi_substep_in_one_cluster_tick() { + let sim = RapierClusterSim::with_default_config(None); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0))); + step_once(&sim, &mut entities, 1, 0.1); // single tick, 100 ms wall-time + let x = entities.get(&id).unwrap().position.x; + // Six substeps × (1/60 s) × 1 m/s = 0.1 m exactly. Allow a tiny epsilon. + assert!( + x > 0.09 && x < 0.11, + "expected ≈0.1 from 6 substeps, got {}", + x + ); + } + + /// **T6**: when each cluster tick's `dt_seconds < FIXED_PHYSICS_DT`, the + /// accumulator grows over multiple ticks before a substep finally fires. + /// Catches a regression where a fast cluster (e.g., 200 Hz) never + /// advances physics because the accumulator path is broken. + #[test] + fn slow_dt_accumulates_until_substep_fires() { + let sim = RapierClusterSim::with_default_config(None); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0))); + + // First tick: dt = 0.005 < FIXED_PHYSICS_DT (0.0167). Accumulator + // shouldn't have drained, so position should still be 0. + step_once(&sim, &mut entities, 1, 0.005); + let after_one = entities.get(&id).unwrap().position.x; + assert!( + after_one.abs() < 1e-6, + "first sub-substep tick should not advance position; got {}", + after_one + ); + + // Continue. Over 30 ticks at 0.005 s = 0.15 s total, ≥ 8 substeps fire. + step_n(&sim, &mut entities, 30, 0.005); + let after_many = entities.get(&id).unwrap().position.x; + // Total motion is roughly total_dt × velocity, minus at most one substep + // worth of leftover accumulator. + assert!( + after_many > 0.10 && after_many < 0.16, + "expected ≈0.15 ± one substep, got {}", + after_many + ); + } + + /// **T7**: contact resolution actually applies impulse — Rapier doesn't + /// just *detect* collisions, it responds to them. Without this, a config + /// that accidentally turned all colliders into sensors (no force exchange) + /// would still pass every other contact test. + #[test] + fn contact_resolution_applies_impulse_to_partner() { + let sim = RapierClusterSim::with_default_config(None); + let mut entities = HashMap::new(); + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + // A heads at B (stationary). After collision, B must have non-zero + // velocity in +x (got pushed) — that's contact response in action. + entities.insert(a, mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 0.0))); + entities.insert(b, mk_entry(b, Vec3::new(2.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + step_n(&sim, &mut entities, 40, CLUSTER_DT); // 2 s — plenty for collision + post-collision + + let b_vel_x = entities.get(&b).unwrap().velocity.x; + let b_pos_x = entities.get(&b).unwrap().position.x; + assert!( + b_vel_x > 1e-3, + "B should have been pushed by contact resolution; vx = {}", + b_vel_x + ); + assert!( + b_pos_x > 2.0, + "B should have moved in +x from contact; pos.x = {}", + b_pos_x + ); + } + + /// **T8**: respawning an entity with the same UUID is a *new* first-sight, + /// so `collider_for` should be invoked again. Important for respawn + /// mechanics where a dead entity comes back as a different shape (e.g., + /// ghost form). + #[test] + fn collider_for_invoked_freshly_on_respawn() { + struct CountedShape { + calls: AtomicU64, + } + impl RapierClusterSimulation for CountedShape { + fn on_tick(&self, _ctx: &mut RapierClusterTickContext<'_>) {} + fn collider_for( + &self, + _entry: &EntityStateEntry, + _config: &RapierConfig, + ) -> RapierColliderShape { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 { + RapierColliderShape::Ball(0.5) + } else { + RapierColliderShape::Cuboid([0.3, 0.3, 0.3]) + } + } + } + let inner = Arc::new(CountedShape { + calls: AtomicU64::new(0), + }); + let sim = RapierClusterSim::with_rapier_sim( + inner.clone() as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(99); + entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + step_once(&sim, &mut entities, 1, CLUSTER_DT); + // First lifetime: Ball. + assert_eq!(inner.calls.load(Ordering::SeqCst), 1); + assert!(with_collider(&sim, id, |c| c.shape().as_ball().is_some()).unwrap_or(false)); + + // Despawn (vanish from map), let despawn_missing fire. + entities.remove(&id); + step_once(&sim, &mut entities, 2, CLUSTER_DT); + + // Respawn same UUID → fresh first-sight → collider_for called again. + entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + step_once(&sim, &mut entities, 3, CLUSTER_DT); + assert_eq!( + inner.calls.load(Ordering::SeqCst), + 2, + "collider_for must be called again on respawn" + ); + assert!( + with_collider(&sim, id, |c| c.shape().as_cuboid().is_some()).unwrap_or(false), + "respawned body should use the second-call shape" + ); + } + + /// **T9**: V2 user receives `game_actions` correctly through the extended + /// context. Symmetry with the V1 `user_on_tick_runs_before_physics_with_correct_context` + /// test for `ClusterTickContext`. + #[test] + fn rapier_ctx_propagates_game_actions_tick_and_dt() { + struct Spy { + seen_tick: AtomicU64, + seen_dt: Mutex, + seen_action_count: AtomicU64, + } + impl RapierClusterSimulation for Spy { + fn on_tick(&self, ctx: &mut RapierClusterTickContext<'_>) { + self.seen_tick.store(ctx.tick, Ordering::SeqCst); + *self.seen_dt.lock().unwrap() = ctx.dt_seconds; + self.seen_action_count + .store(ctx.game_actions.len() as u64, Ordering::SeqCst); + } + } + let spy = Arc::new(Spy { + seen_tick: AtomicU64::new(0), + seen_dt: Mutex::new(0.0), + seen_action_count: AtomicU64::new(0), + }); + let sim = RapierClusterSim::with_rapier_sim( + spy.clone() as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + + let actions = vec![ + GameAction { + entity_id: id, + action_type: "use_item".into(), + payload: serde_json::Value::Null, + }, + GameAction { + entity_id: id, + action_type: "interact".into(), + payload: serde_json::Value::Null, + }, + ]; + let mut pending: Vec = Vec::new(); + let mut ctx = ClusterTickContext { + cluster_id: Uuid::nil(), + tick: 99, + dt_seconds: CLUSTER_DT, + entities: &mut entities, + pending_removals: &mut pending, + game_actions: &actions, + }; + sim.on_tick(&mut ctx); + + assert_eq!(spy.seen_tick.load(Ordering::SeqCst), 99); + assert!(close(*spy.seen_dt.lock().unwrap(), CLUSTER_DT, 1e-9)); + assert_eq!(spy.seen_action_count.load(Ordering::SeqCst), 2); + } + + /// **T10**: V2 user can request entity removal via `pending_removals` — + /// the wrapper despawns those bodies in the same tick. Symmetry with the + /// V1 `user_can_request_removal_via_pending_removals` test. + #[test] + fn rapier_user_can_request_removal_via_pending_removals() { + struct DropAll; + impl RapierClusterSimulation for DropAll { + fn on_tick(&self, ctx: &mut RapierClusterTickContext<'_>) { + let ids: Vec = ctx.entities.keys().copied().collect(); + ctx.pending_removals.extend(ids); + } + } + let sim = RapierClusterSim::with_rapier_sim( + Arc::new(DropAll) as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + for k in 0..4u128 { + let id = Uuid::from_u128(k); + entities.insert( + id, + mk_entry(id, Vec3::new(k as f64, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + } + step_once(&sim, &mut entities, 1, CLUSTER_DT); + assert_eq!(handle_count(&sim), 0); + } + + /// **T11**: a Ball and a Cuboid colliding produce a contact event. Cross- + /// shape collision is exercised by Rapier's narrow phase but never tested + /// elsewhere in this suite (all V2 tests pair same-shape bodies). + #[test] + fn mixed_shape_ball_vs_cuboid_produces_contact() { + let ball_id = Uuid::from_u128(1); + let box_id = Uuid::from_u128(2); + struct MixedShape { + ball_id: Uuid, + events: Mutex>, + } + impl RapierClusterSimulation for MixedShape { + fn on_tick(&self, ctx: &mut RapierClusterTickContext<'_>) { + self.events + .lock() + .unwrap() + .extend_from_slice(ctx.contact_events); + } + fn collider_for( + &self, + entry: &EntityStateEntry, + _config: &RapierConfig, + ) -> RapierColliderShape { + if entry.entity_id == self.ball_id { + RapierColliderShape::Ball(0.5) + } else { + RapierColliderShape::Cuboid([0.5, 0.5, 0.5]) + } + } + } + let recorder = Arc::new(MixedShape { + ball_id, + events: Mutex::new(Vec::new()), + }); + let sim = RapierClusterSim::with_rapier_sim( + recorder.clone() as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + // Ball (radius 0.5) at origin; cuboid (half-extents 0.5) at (0.7, 0, 0) + // → cuboid spans x ∈ [0.2, 1.2]; sphere extends to x = 0.5. Overlap. + entities.insert(ball_id, mk_entry(ball_id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert(box_id, mk_entry(box_id, Vec3::new(0.7, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + step_n(&sim, &mut entities, 2, CLUSTER_DT); + + let events = recorder.events.lock().unwrap().clone(); + assert!( + started_pair_present(&events, ball_id, box_id), + "ball-vs-cuboid Started event missing; events were {:?}", + events + ); + } + + /// **T12**: gravity is honored on any axis, not just `-Y`. A horizontal + /// gravity vector should accelerate a stationary entity in the gravity + /// direction. Catches a regression where gravity is hardcoded to a + /// single axis somewhere. + #[test] + fn nondefault_gravity_honored_on_arbitrary_axis() { + let config = RapierConfig { + gravity: [3.0, 0.0, 0.0], + ..Default::default() + }; + let sim = RapierClusterSim::new(None, config); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + step_n(&sim, &mut entities, 20, CLUSTER_DT); // 1.0 s + + let p = entities.get(&id).unwrap().position; + let v = entities.get(&id).unwrap().velocity; + // Free-fall along +X: pos ≈ 0.5·g·t² ≈ 1.5; vx ≈ g·t = 3. + // Wide tolerance for semi-implicit Euler at 1/60 substeps. + assert!(p.x > 1.3, "x should accelerate in +x under +x gravity; got {}", p.x); + assert!(v.x > 2.7, "vx should grow under +x gravity; got {}", v.x); + // No motion on other axes. + assert!(p.y.abs() < 1e-3 && p.z.abs() < 1e-3); + assert!(v.y.abs() < 1e-3 && v.z.abs() < 1e-3); + } + + /// **T13**: when an entity hands off from one cluster to another (via + /// despawn-and-respawn on the new cluster), the receiving cluster's + /// `contact_events` start empty — contacts don't carry across the + /// hand-off. Documented contract; pin it explicitly. + #[test] + fn contact_events_do_not_carry_across_handoff() { + let recorder_a = ContactRecorder::new(RapierColliderShape::Ball(0.4)); + let sim_a = RapierClusterSim::with_rapier_sim( + recorder_a.clone() as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + entities.insert(a, mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert(b, mk_entry(b, Vec3::new(0.5, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + step_n(&sim_a, &mut entities, 3, CLUSTER_DT); + let events_a = recorder_a.snapshot(); + assert!( + !events_a.is_empty(), + "cluster A should have observed contacts before handoff" + ); + + // Hand off: drop sim_a, build sim_b with same entity state but a new + // recorder. Sim_b's first on_tick must see contact_events == &[]. + drop(sim_a); + let recorder_b = ContactRecorder::new(RapierColliderShape::Ball(0.4)); + let sim_b = RapierClusterSim::with_rapier_sim( + recorder_b.clone() as Arc, + RapierConfig::default(), + ); + + let actions: Vec = Vec::new(); + let mut pending: Vec = Vec::new(); + let mut ctx = ClusterTickContext { + cluster_id: Uuid::nil(), + tick: 1, + dt_seconds: CLUSTER_DT, + entities: &mut entities, + pending_removals: &mut pending, + game_actions: &actions, + }; + sim_b.on_tick(&mut ctx); + + // Cluster B's recorder should not have seen any of cluster A's events. + let events_b_first_tick = recorder_b.snapshot(); + assert!( + events_b_first_tick.is_empty(), + "cluster B's first tick must not inherit cluster A's contacts; got {:?}", + events_b_first_tick + ); + } + + /// **T14**: `RapierColliderShape::Capsule` is built along the **Y** axis. + /// Verifies the documented orientation by inspecting the resulting shape's + /// segment endpoints. + #[test] + fn capsule_axis_is_y() { + let recorder = ContactRecorder::new(RapierColliderShape::Capsule { + half_height: 1.5, + radius: 0.3, + }); + let sim = RapierClusterSim::with_rapier_sim( + recorder.clone() as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + let id = Uuid::from_u128(1); + entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + step_once(&sim, &mut entities, 1, CLUSTER_DT); + + let segment = with_collider(&sim, id, |c| { + c.shape().as_capsule().map(|cap| cap.segment) + }) + .flatten() + .expect("collider should be a Capsule"); + // capsule_y: endpoints at (0, ±half_height, 0). + assert!((segment.a.x).abs() < 1e-6); + assert!((segment.a.z).abs() < 1e-6); + assert!((segment.b.x).abs() < 1e-6); + assert!((segment.b.z).abs() < 1e-6); + let y_extent = (segment.b.y - segment.a.y).abs(); + assert!( + (y_extent - 3.0).abs() < 1e-5, + "expected segment along Y of length 2·half_height = 3.0; got {}", + y_extent + ); + } } From f91522d2d43c45e7d320572e9bf9ae7ad7aea6da Mon Sep 17 00:00:00 2001 From: martinjms Date: Sun, 3 May 2026 16:20:21 +0300 Subject: [PATCH 4/5] docs(architecture): add entity-model.md; pin durable-state-per-entity invariant; clarify body kinds in physics backends doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New canonical doc `docs/architecture/entity-model.md` codifies the unified- entity model decided in the 2026-05-03 architecture session: - Arcane has one persistent-world concept: the Entity. Players, NPCs, projectiles, dropped items, structures, player-built walls — all are entities differentiated by per-entity hooks (body kind, collider, material, collision groups, sensor mode), not by separate types at the platform level. Matches modern engine practice (Unreal AActor, Unity GameObject, Bevy ECS, Godot Node). - Two-axis classification (animate × moving/stationary) is described with industry-standard term cross-references. - Physics body kinds (Dynamic / KinematicPositionBased / KinematicVelocityBased / Fixed) are documented with their per-tick cost and migration semantics. - Affinity-bound vs spatial-bound distinction is called out as a clustering concern (not a physics concern); needs follow-up work in the clustering model so Fixed entities don't migrate by PGP affinity. - Terrain is explicitly NOT entities — it's content loaded by the Arcane runtime based on entity positions. Cross-link to terrain epic #119. Updates to `four-bucket-state-model.md`: - Adds the "every entity has bucket-4 durable state" universal invariant up front. This is what makes recovery / migration work and what unifies ephemeral game objects with structural ones in a single concept. Updates to `physics-backends-and-unreal.md` §6 (entity ↔ body mapping): - Adds body-kind row (per-entity hook, default Dynamic). - Adds explicit terrain-is-not-entities row with cross-link to #119. - Notes Rapier's sleep mechanism + Fixed-body solver-skip preserve the "no entities → no simulation" invariant without needing a separate Structure concept. Refs #117, #118, #119, #120, #121, #122, #8, #33. Co-Authored-By: Claude Opus 4.7 --- docs/architecture/entity-model.md | 136 ++++++++++++++++++ docs/architecture/four-bucket-state-model.md | 8 ++ .../physics-backends-and-unreal.md | 12 +- 3 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 docs/architecture/entity-model.md diff --git a/docs/architecture/entity-model.md b/docs/architecture/entity-model.md new file mode 100644 index 0000000..3df3f41 --- /dev/null +++ b/docs/architecture/entity-model.md @@ -0,0 +1,136 @@ +# Entity model + +This document is the **canonical reference** for what an *entity* is in Arcane, what kinds exist, how they relate to physics simulation and clustering, and how they relate to the world's terrain. It complements [four-bucket-state-model.md](four-bucket-state-model.md) (which defines where an entity's *state* lives) and [physics-backends-and-unreal.md](physics-backends-and-unreal.md) (which defines how an entity's *body* is simulated). + +--- + +## 1. The unified entity model + +Arcane has **one** persistent-world concept: the **Entity**. Players, NPCs, projectiles, dropped items, placed structures, player-built walls — every persistent thing in the game world that can be addressed, replicated, simulated, or destroyed is an entity. There is **no separate** "Structure," "GameObject," "Tile Entity," "Placeable," or similar category at the platform level. + +This consolidation matches modern engine practice. Unreal's `AActor`, Unity's `GameObject`, Bevy's ECS Entity, and Godot's `Node3D` all use a single base concept differentiated by configuration (body kind, components, mobility flags). Older designs that split persistent things into `Unit` vs `GameObject` (WoW), `Entity` vs `Tile Entity` (Minecraft), or `APawn` vs `AStaticMeshActor` (early Unreal) have not aged well — they bake assumptions ("static things never become dynamic," "dynamic things don't have durable state") into the type system that real games then have to fight. + +Game developers using Arcane define their **own subclasses or type categories** of Entity to express game-specific behavior (e.g. `Player`, `NPC`, `Projectile`, `Wall`, `Chest`). The platform doesn't enumerate these. What the platform exposes is a small set of **per-entity hooks** that determine how each entity is treated by physics and clustering — body kind, collider shape, material, collision groups, sensor status. The game's subclasses pick the right hook return values for their purposes. + +--- + +## 2. Universal entity properties + +Every entity, regardless of kind, has the following: + +| Property | Where it lives | Notes | +|---|---|---| +| **Stable ID** | `EntityStateEntry::entity_id` (UUID) | Survives cluster restarts, migrations, durable persistence. | +| **Cluster ownership** | `EntityStateEntry::cluster_id` | The cluster currently authoritative for this entity. May change at migration time. | +| **Position + velocity (spine pose)** | `EntityStateEntry::position` / `velocity` | Bucket 1 of the four-bucket state model. Replicated every tick. | +| **Replicated user data** | `EntityStateEntry::user_data` (JSON) | Bucket 2 — game-defined fields that must reach neighbors and clients. | +| **Cluster-local ephemeral data** | `EntityStateEntry::local_data` (JSON) | Bucket 3 — process-only scratch, not on the wire. | +| **Durable state** | SpacetimeDB row | Bucket 4. **Every entity has durable state in SpacetimeDB.** This is an invariant of the platform. | + +The "every entity has SpacetimeDB durable state" invariant is what makes recovery possible: cluster crash, cluster restart, cluster migration, every kind of disruption can rehydrate the entity from its durable row. It also unifies the lifecycle — there is no class of persistent thing in the world that lives outside SpacetimeDB. + +--- + +## 3. Two-axis classification + +Entities differ along two orthogonal axes: + +| | **Animate** (has AI / will / behavior) | **Inanimate** (no will, no AI) | +|---|---|---| +| **Moving** | Player running, NPC walking, NPC pathfinding | Thrown box, projectile, falling debris, ragdoll, dropped item still settling | +| **Stationary** | NPC standing idle, AFK player, sleeping NPC | Wall, foundation, placed structure, dropped item at rest, world boss anchor | + +Two practical observations follow from this: + +- **Animation** is a **game-logic** distinction. The game tracks whether an entity has AI / behavior / inputs. The platform doesn't care — it's expressed in `user_data` or in the game's own SpacetimeDB schema. +- **Motion state** is **dynamic**, not static. A wall sits at rest (stationary, inanimate); a player attack sends it falling (moving, inanimate); when the rubble settles it becomes stationary again. **Same entity, same body, just changing motion state.** Physics engines (including Rapier) handle this via *sleeping* — a body at rest is automatically marked inactive, costs essentially zero per tick, and wakes when something hits it. + +The platform's job isn't to rigidly classify entities — it's to express the *physical* differences cleanly so that the game's semantic categories ride on top. + +--- + +## 4. Physics body kinds + +Physics layer differentiates entities by **body kind**, which is what Rapier (and other physics engines) actually need: + +| Body kind | Used for | Per-tick cost | Migration semantics | +|---|---|---|---| +| **Dynamic** | Players, NPCs (when physics-driven), projectiles, thrown objects, ragdolls, debris | Full simulation | Affinity-bound (PGP) | +| **KinematicPositionBased** | Player-controlled characters with custom locomotion, moving platforms, elevators | Position controlled by game logic; physics doesn't apply forces | Affinity-bound (PGP) | +| **KinematicVelocityBased** | Some character controllers; physics-integrated game-controlled velocity | Mid-ground between Dynamic and KinematicPositionBased | Affinity-bound (PGP) | +| **Fixed** | Walls, foundations, placed structures, permanent world fixtures (when expressed as entities) | Skipped by the solver entirely; only AABB tracking in broadphase | **Spatial-bound** (see §6) | + +Body kind is declared per-entity at first-sight via the `body_kind_for` hook on `RapierClusterSimulation` (and analogous hooks on future Unreal/Unity backends). Default is `Dynamic`. + +**Sleeping bodies** are how a cluster with thousands of stationary entities stays cheap: a wall sleeps, a placed crate sleeps, an idle NPC's body sleeps. Cost is roughly proportional to *active* (awake) bodies, not to total entity count. The "cluster with no entities pays no simulation cost" invariant is preserved because Fixed bodies don't iterate at all and other body kinds sleep when at rest. + +--- + +## 5. Subclass vs property-value polymorphism + +The unified-entity model does **not** mandate how the game expresses kind-specific behavior in code. Two equally-valid approaches: + +- **Subclass-style** (Java / C#-like): the game defines `Player`, `NPC`, `Wall`, `Chest` as separate types that each implement `RapierClusterSimulation` with their own per-entity hooks. The platform calls the right impl per entity. +- **Property-value-style** (functional / Bevy ECS-like): the game has a single `RapierClusterSimulation` impl that matches on a kind field in `user_data` (or on the entity's SpacetimeDB row) and returns the appropriate body kind, shape, etc. + +Both patterns work. The platform exposes the same hooks; the game picks the structure. Subclass-style tends to be more ergonomic for games with a small fixed catalog of entity kinds; property-value-style tends to be cleaner for games with many kinds or runtime-configurable kinds. + +--- + +## 6. Affinity-bound vs spatial-bound — the clustering distinction + +Where the unified-entity model **does** introduce a real architectural distinction is at the **clustering** layer, not the physics layer. + +- **Affinity-bound entities** migrate by social signal (PGP). A player and their party-mates end up on the same cluster because they interact frequently. A projectile fired by a player follows the player's affinity. Examples: most Dynamic and Kinematic entities. +- **Spatial-bound entities** are tied to their position. They do not migrate by affinity — they only "migrate" when ownership of the map chunk they sit in changes hands. A wall built in the eastern desert stays in whichever cluster owns the eastern-desert chunk; it does not follow the guild that built it when the guild goes raiding 10km away. Examples: most Fixed entities. + +The physics body kind correlates with binding (Fixed → spatial; Dynamic / Kinematic → affinity), but **the clustering model needs explicit binding information to make migration decisions**, because the clustering layer doesn't reach into physics. The cleanest encoding is an explicit `binding: EntityBinding { Affinity, Spatial }` field on the durable state, separate from physics body kind. (This is its own piece of design work — see the related epic.) + +A consequence of spatial-bound entities is that **clustering is not driven purely by entity affinity any more**. The cluster manager has to balance: + +- *Affinity-bound entities* — placed by the social-affinity model. +- *Spatial-bound entities* — placed by chunk ownership; the manager must arrange for a cluster's chunk responsibility to align with the spatial-bound entities sitting in those chunks. + +This is the same kind of reasoning the manager already does for player movement (assigning chunks based on player density), generalized to "spatial-bound entities also influence which chunks a cluster wants." + +--- + +## 7. Terrain is NOT entities + +This is the only thing in the world that's *not* an entity: + +| Aspect | Entity | Terrain | +|---|---|---| +| Identity | Stable UUID | None — terrain has no identity beyond chunk addressing | +| State | SpacetimeDB durable + spine pose + replicated user_data + local_data | None — terrain is *content*, not *state* | +| Storage | SpacetimeDB | Map asset on disk | +| Replication | Per-tick deltas | Not replicated — clients have own copy of map assets | +| Authority / ownership | A specific cluster at a given moment | Whichever cluster currently has chunks loaded; non-authoritative — every cluster reads the same map data | +| Mutability at runtime | Fully mutable | Read-only | + +If a game has destructible terrain (a hole punched in a wall, dirt mined out), that destruction is modeled as **entities**, not as edits to the terrain. A wall that can be destroyed is an entity with `Fixed` body kind and durable HP state. Mining a tunnel is the despawn of a series of voxel-entities, leaving the underlying terrain mesh intact. The terrain layer is read-only; everything mutable is expressed at the entity layer. + +**Game developers do not insert terrain geometry into physics by hand.** The Arcane runtime is responsible for loading the right map collision into a cluster's physics world based on which entities the cluster currently owns and where they are. See [issue #119](https://github.com/brainy-bots/arcane/issues/119) for the terrain-loading epic. + +--- + +## 8. Cross-references + +| Topic | Doc | +|---|---| +| Entity state buckets (where each field lives) | [four-bucket-state-model.md](four-bucket-state-model.md) | +| Physics integration shape (Unreal/Chaos and Rapier) | [physics-backends-and-unreal.md](physics-backends-and-unreal.md) | +| Affinity clustering / PGP | [interface-iclusteringmodel.md](interface-iclusteringmodel.md) | +| Replication channels and wire format | [interface-ireplicationchannel.md](interface-ireplicationchannel.md) | + +--- + +## 9. Industry-standard terminology cross-references + +When discussing Arcane's entity model with people from other engines: + +- Arcane "Entity" ≈ Unreal `AActor`, Unity `GameObject`, Bevy ECS Entity, Godot `Node3D`. +- Arcane "Body kind = Fixed" ≈ Unreal Mobility=Static, Unity `Rigidbody.isKinematic`+`isStatic`, Rapier `RigidBodyType::Fixed`, PhysX `PxRigidStatic`. +- Arcane "Affinity-bound" ≈ migrating actor in seamless world meshing (Star Citizen / SpatialOS terminology). +- Arcane "Spatial-bound" ≈ chunk-owned actor / persistent-placeable (Conan Exiles, Ark, Valheim terminology). +- Arcane "Terrain" ≈ static mesh / level geometry / world chunks (universally understood). diff --git a/docs/architecture/four-bucket-state-model.md b/docs/architecture/four-bucket-state-model.md index bd07b66..4bbc57d 100644 --- a/docs/architecture/four-bucket-state-model.md +++ b/docs/architecture/four-bucket-state-model.md @@ -2,6 +2,14 @@ This document is the **canonical reference** for where game data lives in Arcane + SpacetimeDB. Integrators, reviewers, and client authors should align with it. +> **Companion doc:** [entity-model.md](entity-model.md) defines what an *entity* is and what kinds exist. This doc defines where an entity's *state* lives across the four buckets. + +## Universal invariant: every entity has bucket-4 durable state + +Every entity in Arcane has a row in SpacetimeDB. Bucket 4 is **not optional** — there is no class of persistent thing in the world that lives only in buckets 1–3 and can disappear when a cluster process crashes. This invariant is what makes recovery possible (rehydrate an entity from its durable row after a crash, restart, or migration) and what makes the unified-entity model work for both ephemeral game objects (projectiles that exist for milliseconds) and structural game objects (walls placed by a player years ago). + +Game-specific schemas extend bucket 4 with their own tables and reducers; the invariant is that *some* SpacetimeDB row exists for every `entity_id` the platform sees. + --- ## 1. Why four buckets diff --git a/docs/architecture/physics-backends-and-unreal.md b/docs/architecture/physics-backends-and-unreal.md index c0e6c53..8d071d3 100644 --- a/docs/architecture/physics-backends-and-unreal.md +++ b/docs/architecture/physics-backends-and-unreal.md @@ -68,14 +68,18 @@ Maintain a **bidirectional map** in the integration layer: | Concept | Responsibility | |---------|----------------| | `entity_id` (`Uuid`) | Stable Arcane / wire identity; store as FGuid or string in UE per project convention. | -| Chaos actor / body | Spawn when a new owned entity appears in the authoritative set; destroy when removed or when `pending_removals`-style lifecycle fires. | -| Neighbor entities | **Policy (v1):** treat as **kinematic** or **pose-only** proxies — do not double-simulate. Full cross-cluster coupling is game-specific. | +| Chaos / Rapier actor / body | Spawn when a new owned entity appears in the authoritative set; destroy when removed or when `pending_removals`-style lifecycle fires. | +| Neighbor entities | **Policy:** treat as **kinematic** or **pose-only** proxies — do not double-simulate. Full cross-cluster coupling is deferred work (see clustering-binding epic). | | `user_data` | Optional: stiffness, hitbox id, team — replicated; keep small. | | `local_data` | Solver scratch, cooldowns — **not** on Redis wire; see [four-bucket-state-model.md](four-bucket-state-model.md). | +| **Body kind** | Per-entity, declared at first-sight via `body_kind_for` hook. `Dynamic` (players, projectiles, debris), `KinematicPositionBased` / `KinematicVelocityBased` (server-controlled motion), `Fixed` (walls, placed structures). Default `Dynamic`. See [entity-model.md §4](entity-model.md). | +| **Terrain / world geometry** | **Not entities.** Loaded into the cluster's physics world automatically by the Arcane runtime based on entity positions. Game developers do not insert terrain colliders by hand. See [issue #119](https://github.com/brainy-bots/arcane/issues/119). | -**Spawn sync:** On first sight of `entity_id`, create default Chaos representation (capsule/box) from game data or `user_data` schema version. +**Spawn sync:** On first sight of `entity_id`, create the appropriate body via the `body_kind_for` + `collider_for` hooks. Default body kind is `Dynamic` with a sphere collider matching `RapierConfig::default_body_radius`. -**Despawn:** On removal from cluster authority or `pending_removals`, destroy Chaos objects and clear handles. +**Despawn:** On removal from cluster authority or `pending_removals`, destroy physics objects and clear handles. + +**Sleeping bodies:** stationary Dynamic / Kinematic bodies and all Fixed bodies are essentially free per tick — Rapier's sleep mechanism + Fixed-body solver-skip means a cluster with hundreds of stationary entities pays cost proportional only to active (awake) bodies. The "no entities → no simulation" intuition is preserved by this mechanism without needing a separate concept for stationary objects. --- From f0938c95d5f018ae896c6e77169121575606139f Mon Sep 17 00:00:00 2001 From: martinjms Date: Sun, 3 May 2026 16:49:42 +0300 Subject: [PATCH 5/5] =?UTF-8?q?style:=20cargo=20fmt=20=E2=80=94=20break=20?= =?UTF-8?q?long=20entities.insert=20/=20assert!=20/=20closure-chain=20line?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's `cargo fmt --check` flagged six sites in rapier_cluster.rs (V2 tests with long-form `entities.insert(id, mk_entry(...))` calls and one chained with_collider closure) plus one site in cluster_runner.rs (long-line Uuid::parse_str with map_err). Pure formatting; no functional changes. All 38 rapier_cluster tests pass; clippy silent both modes. Refs #117, #118, #123. Co-Authored-By: Claude Opus 4.7 --- crates/arcane-infra/src/cluster_runner.rs | 4 +- crates/arcane-infra/src/rapier_cluster.rs | 242 +++++++++++++++++----- 2 files changed, 192 insertions(+), 54 deletions(-) diff --git a/crates/arcane-infra/src/cluster_runner.rs b/crates/arcane-infra/src/cluster_runner.rs index ecb353d..afb048d 100644 --- a/crates/arcane-infra/src/cluster_runner.rs +++ b/crates/arcane-infra/src/cluster_runner.rs @@ -48,8 +48,8 @@ impl ClusterEnv { pub fn from_env() -> Result { let cluster_id = std::env::var("CLUSTER_ID") .map_err(|_| "CLUSTER_ID env var required (UUID)".to_string())?; - let cluster_id = Uuid::parse_str(&cluster_id) - .map_err(|e| format!("invalid CLUSTER_ID: {}", e))?; + let cluster_id = + Uuid::parse_str(&cluster_id).map_err(|e| format!("invalid CLUSTER_ID: {}", e))?; let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); let neighbor_ids = std::env::var("NEIGHBOR_IDS") diff --git a/crates/arcane-infra/src/rapier_cluster.rs b/crates/arcane-infra/src/rapier_cluster.rs index c7e5bf9..374cb14 100644 --- a/crates/arcane-infra/src/rapier_cluster.rs +++ b/crates/arcane-infra/src/rapier_cluster.rs @@ -150,7 +150,9 @@ fn build_collider(shape: RapierColliderShape) -> Collider { } => ColliderBuilder::capsule_y(half_height, radius), RapierColliderShape::Cuboid(he) => ColliderBuilder::cuboid(he[0], he[1], he[2]), }; - builder.active_events(ActiveEvents::COLLISION_EVENTS).build() + builder + .active_events(ActiveEvents::COLLISION_EVENTS) + .build() } /// A collision detected during a Rapier step, mapped from Rapier's collider @@ -669,7 +671,11 @@ mod tests { // the body already exists and writes Rapier output (still 0,0,0) back. step_n(&sim, &mut entities, 3, CLUSTER_DT); let p = entities.get(&id).unwrap().position; - assert!(p.x.abs() < 1e-3 && p.y.abs() < 1e-3 && p.z.abs() < 1e-3, "{:?}", p); + assert!( + p.x.abs() < 1e-3 && p.y.abs() < 1e-3 && p.z.abs() < 1e-3, + "{:?}", + p + ); } #[test] @@ -706,7 +712,10 @@ mod tests { let mut entities = HashMap::new(); for k in 0..5u128 { let id = Uuid::from_u128(k); - entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); } // First tick spawns 5 bodies, then the wrapper sees pending_removals with all // 5 ids and despawns them. @@ -739,7 +748,11 @@ mod tests { ); step_once(&sim, &mut entities, 6, CLUSTER_DT); let p = entities.get(&id).unwrap().position; - assert!(close(p.x, -100.0, 1e-3), "fresh body should start at -100, got {}", p.x); + assert!( + close(p.x, -100.0, 1e-3), + "fresh body should start at -100, got {}", + p.x + ); assert!(close(p.y, 5.0, 1e-3), "fresh body y, got {}", p.y); assert_eq!(handle_count(&sim), 1); } @@ -768,13 +781,25 @@ mod tests { let pb = entities.get(&b).unwrap().position; let pc = entities.get(&c).unwrap().position; // Each entity should have moved by its own velocity vector × elapsed time. - assert!(close(pa.x - a_start.x, 1.0, SUBSTEP_TOL), "Δa.x = {}", pa.x - a_start.x); + assert!( + close(pa.x - a_start.x, 1.0, SUBSTEP_TOL), + "Δa.x = {}", + pa.x - a_start.x + ); assert!((pa.y - a_start.y).abs() < SUBSTEP_TOL); assert!((pa.z - a_start.z).abs() < SUBSTEP_TOL); - assert!(close(pb.y - b_start.y, 2.0, 2.0 * SUBSTEP_TOL), "Δb.y = {}", pb.y - b_start.y); + assert!( + close(pb.y - b_start.y, 2.0, 2.0 * SUBSTEP_TOL), + "Δb.y = {}", + pb.y - b_start.y + ); assert!((pb.x - b_start.x).abs() < SUBSTEP_TOL); assert!((pb.z - b_start.z).abs() < SUBSTEP_TOL); - assert!(close(pc.z - c_start.z, -3.0, 3.0 * SUBSTEP_TOL), "Δc.z = {}", pc.z - c_start.z); + assert!( + close(pc.z - c_start.z, -3.0, 3.0 * SUBSTEP_TOL), + "Δc.z = {}", + pc.z - c_start.z + ); assert!((pc.x - c_start.x).abs() < SUBSTEP_TOL); assert!((pc.y - c_start.y).abs() < SUBSTEP_TOL); } @@ -791,7 +816,11 @@ mod tests { let col = (k % 25) as f64; entities.insert( id, - mk_entry(id, Vec3::new(col * 5.0, 0.0, row * 5.0), Vec3::new(1.0, 0.0, 0.0)), + mk_entry( + id, + Vec3::new(col * 5.0, 0.0, row * 5.0), + Vec3::new(1.0, 0.0, 0.0), + ), ); } step_n(&sim, &mut entities, 20, CLUSTER_DT); // 1.0 s @@ -928,7 +957,12 @@ mod tests { step_once(&sim, &mut entities, tick + 1, CLUSTER_DT); let vy = entities.get(&id).unwrap().velocity.y; if let Some(prev) = prev_vy { - assert!(vy < prev, "vy must monotonically decrease under -y gravity (was {}, now {})", prev, vy); + assert!( + vy < prev, + "vy must monotonically decrease under -y gravity (was {}, now {})", + prev, + vy + ); } prev_vy = Some(vy); } @@ -1013,7 +1047,11 @@ mod tests { assert_eq!(spy.last_action_count.load(Ordering::SeqCst), 1); // Rapier saw the velocity the spy wrote (5.0) → entity advances along x. let p = entities.get(&id).unwrap().position; - assert!(p.x > 0.0, "Rapier should have applied user-written velocity, x = {}", p.x); + assert!( + p.x > 0.0, + "Rapier should have applied user-written velocity, x = {}", + p.x + ); } #[test] @@ -1050,8 +1088,15 @@ mod tests { step_n(&sim2, &mut entities2, 3, CLUSTER_DT); let buffed_x = entities2.get(&id).unwrap().position.x; let buffed_vx = entities2.get(&id).unwrap().velocity.x; - assert!(buffed_x > baseline_x / 5.0, "buff should produce more motion per tick"); - assert!(buffed_vx >= 8.0, "vx should have doubled 3× to ≥ 8, got {}", buffed_vx); + assert!( + buffed_x > baseline_x / 5.0, + "buff should produce more motion per tick" + ); + assert!( + buffed_vx >= 8.0, + "vx should have doubled 3× to ≥ 8, got {}", + buffed_vx + ); } // ─── determinism / hand-off ───────────────────────────────────────────────── @@ -1106,7 +1151,7 @@ mod tests { mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)), ); step_n(&sim_a, &mut entities, 20, CLUSTER_DT); // 1 s on cluster A - // Hand-off: capture entry, drop sim_a, respawn on sim_b. + // Hand-off: capture entry, drop sim_a, respawn on sim_b. let exported = entities.get(&id).unwrap().clone(); drop(sim_a); let sim_b = RapierClusterSim::with_default_config(None); @@ -1196,8 +1241,14 @@ mod tests { let a = Uuid::from_u128(1); let b = Uuid::from_u128(2); // Centers 0.4 apart with radius 0.5 each → significant overlap. - entities.insert(a, mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); - entities.insert(b, mk_entry(b, Vec3::new(0.4, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + a, + mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + entities.insert( + b, + mk_entry(b, Vec3::new(0.4, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); // Tick 1 spawns and steps; contact emitted post-step. Tick 2 surfaces it. step_n(&sim, &mut entities, 2, CLUSTER_DT); @@ -1223,8 +1274,14 @@ mod tests { let a = Uuid::from_u128(1); let b = Uuid::from_u128(2); // 100 units apart — well outside any collider radius. - entities.insert(a, mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); - entities.insert(b, mk_entry(b, Vec3::new(100.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + a, + mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + entities.insert( + b, + mk_entry(b, Vec3::new(100.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); step_n(&sim, &mut entities, 5, CLUSTER_DT); @@ -1243,7 +1300,10 @@ mod tests { ); let mut entities = HashMap::new(); let id = Uuid::from_u128(1); - entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); step_once(&sim, &mut entities, 1, CLUSTER_DT); let state = sim.state.lock().unwrap(); @@ -1296,7 +1356,10 @@ mod tests { ); let mut entities = HashMap::new(); let id = Uuid::from_u128(1); - entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); step_n(&sim, &mut entities, 5, CLUSTER_DT); assert_eq!( @@ -1352,8 +1415,14 @@ mod tests { let mut entities = HashMap::new(); let a = Uuid::from_u128(1); let b = Uuid::from_u128(2); - entities.insert(a, mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); - entities.insert(b, mk_entry(b, Vec3::new(0.4, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + a, + mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + entities.insert( + b, + mk_entry(b, Vec3::new(0.4, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); step_n(&sim, &mut entities, 3, CLUSTER_DT); @@ -1361,7 +1430,10 @@ mod tests { // Tick 1 must see 0 contacts (nothing has stepped yet from this sim's // perspective; pending_contact_events starts empty). assert_eq!(snapshots[0].0, 1); - assert_eq!(snapshots[0].1, 0, "tick 1 should have no contact events yet"); + assert_eq!( + snapshots[0].1, 0, + "tick 1 should have no contact events yet" + ); // Tick 2 must see at least one contact (the Started from tick 1's step). assert_eq!(snapshots[1].0, 2); assert!( @@ -1385,8 +1457,14 @@ mod tests { let mut entities = HashMap::new(); let a = Uuid::from_u128(1); let b = Uuid::from_u128(2); - entities.insert(a, mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); - entities.insert(b, mk_entry(b, Vec3::new(0.6, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + a, + mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + entities.insert( + b, + mk_entry(b, Vec3::new(0.6, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); step_n(&sim, &mut entities, 20, CLUSTER_DT); @@ -1437,8 +1515,14 @@ mod tests { let a = Uuid::from_u128(1); let b = Uuid::from_u128(2); // Start overlapping so Started fires immediately. - entities.insert(a, mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); - entities.insert(b, mk_entry(b, Vec3::new(0.6, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + a, + mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + entities.insert( + b, + mk_entry(b, Vec3::new(0.6, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); step_n(&sim, &mut entities, 2, CLUSTER_DT); assert!( started_pair_present(&recorder.snapshot(), a, b), @@ -1499,8 +1583,14 @@ mod tests { RapierConfig::default(), ); let mut entities = HashMap::new(); - entities.insert(a, mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); - entities.insert(b, mk_entry(b, Vec3::new(0.5, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + a, + mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + entities.insert( + b, + mk_entry(b, Vec3::new(0.5, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); step_n(&sim, &mut entities, 6, CLUSTER_DT); @@ -1529,7 +1619,10 @@ mod tests { let sim = RapierClusterSim::new(None, config); let mut entities = HashMap::new(); let id = Uuid::from_u128(1); - entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); step_once(&sim, &mut entities, 1, CLUSTER_DT); let radius = with_collider(&sim, id, |c| c.shape().as_ball().map(|b| b.radius)) @@ -1553,12 +1646,16 @@ mod tests { ); let mut entities = HashMap::new(); let id = Uuid::from_u128(1); - entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); step_once(&sim, &mut entities, 1, CLUSTER_DT); - let capsule_radius = with_collider(&sim, id, |c| c.shape().as_capsule().map(|cap| cap.radius)) - .flatten() - .expect("collider should be a Capsule"); + let capsule_radius = + with_collider(&sim, id, |c| c.shape().as_capsule().map(|cap| cap.radius)) + .flatten() + .expect("collider should be a Capsule"); assert!((capsule_radius - 0.4).abs() < 1e-6); } @@ -1573,7 +1670,10 @@ mod tests { let sim = RapierClusterSim::with_default_config(None); let mut entities = HashMap::new(); let id = Uuid::from_u128(1); - entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0))); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)), + ); step_once(&sim, &mut entities, 1, 0.1); // single tick, 100 ms wall-time let x = entities.get(&id).unwrap().position.x; // Six substeps × (1/60 s) × 1 m/s = 0.1 m exactly. Allow a tiny epsilon. @@ -1593,7 +1693,10 @@ mod tests { let sim = RapierClusterSim::with_default_config(None); let mut entities = HashMap::new(); let id = Uuid::from_u128(1); - entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0))); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)), + ); // First tick: dt = 0.005 < FIXED_PHYSICS_DT (0.0167). Accumulator // shouldn't have drained, so position should still be 0. @@ -1629,8 +1732,14 @@ mod tests { let b = Uuid::from_u128(2); // A heads at B (stationary). After collision, B must have non-zero // velocity in +x (got pushed) — that's contact response in action. - entities.insert(a, mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 0.0))); - entities.insert(b, mk_entry(b, Vec3::new(2.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + a, + mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 0.0)), + ); + entities.insert( + b, + mk_entry(b, Vec3::new(2.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); step_n(&sim, &mut entities, 40, CLUSTER_DT); // 2 s — plenty for collision + post-collision let b_vel_x = entities.get(&b).unwrap().velocity.x; @@ -1680,7 +1789,10 @@ mod tests { ); let mut entities = HashMap::new(); let id = Uuid::from_u128(99); - entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); step_once(&sim, &mut entities, 1, CLUSTER_DT); // First lifetime: Ball. assert_eq!(inner.calls.load(Ordering::SeqCst), 1); @@ -1691,7 +1803,10 @@ mod tests { step_once(&sim, &mut entities, 2, CLUSTER_DT); // Respawn same UUID → fresh first-sight → collider_for called again. - entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); step_once(&sim, &mut entities, 3, CLUSTER_DT); assert_eq!( inner.calls.load(Ordering::SeqCst), @@ -1733,7 +1848,10 @@ mod tests { ); let mut entities = HashMap::new(); let id = Uuid::from_u128(1); - entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); let actions = vec![ GameAction { @@ -1832,8 +1950,14 @@ mod tests { let mut entities = HashMap::new(); // Ball (radius 0.5) at origin; cuboid (half-extents 0.5) at (0.7, 0, 0) // → cuboid spans x ∈ [0.2, 1.2]; sphere extends to x = 0.5. Overlap. - entities.insert(ball_id, mk_entry(ball_id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); - entities.insert(box_id, mk_entry(box_id, Vec3::new(0.7, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + ball_id, + mk_entry(ball_id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + entities.insert( + box_id, + mk_entry(box_id, Vec3::new(0.7, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); step_n(&sim, &mut entities, 2, CLUSTER_DT); let events = recorder.events.lock().unwrap().clone(); @@ -1857,14 +1981,21 @@ mod tests { let sim = RapierClusterSim::new(None, config); let mut entities = HashMap::new(); let id = Uuid::from_u128(1); - entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); step_n(&sim, &mut entities, 20, CLUSTER_DT); // 1.0 s let p = entities.get(&id).unwrap().position; let v = entities.get(&id).unwrap().velocity; // Free-fall along +X: pos ≈ 0.5·g·t² ≈ 1.5; vx ≈ g·t = 3. // Wide tolerance for semi-implicit Euler at 1/60 substeps. - assert!(p.x > 1.3, "x should accelerate in +x under +x gravity; got {}", p.x); + assert!( + p.x > 1.3, + "x should accelerate in +x under +x gravity; got {}", + p.x + ); assert!(v.x > 2.7, "vx should grow under +x gravity; got {}", v.x); // No motion on other axes. assert!(p.y.abs() < 1e-3 && p.z.abs() < 1e-3); @@ -1885,8 +2016,14 @@ mod tests { let mut entities = HashMap::new(); let a = Uuid::from_u128(1); let b = Uuid::from_u128(2); - entities.insert(a, mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); - entities.insert(b, mk_entry(b, Vec3::new(0.5, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + a, + mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + entities.insert( + b, + mk_entry(b, Vec3::new(0.5, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); step_n(&sim_a, &mut entities, 3, CLUSTER_DT); let events_a = recorder_a.snapshot(); assert!( @@ -1939,14 +2076,15 @@ mod tests { ); let mut entities = HashMap::new(); let id = Uuid::from_u128(1); - entities.insert(id, mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0))); + entities.insert( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); step_once(&sim, &mut entities, 1, CLUSTER_DT); - let segment = with_collider(&sim, id, |c| { - c.shape().as_capsule().map(|cap| cap.segment) - }) - .flatten() - .expect("collider should be a Capsule"); + let segment = with_collider(&sim, id, |c| c.shape().as_capsule().map(|cap| cap.segment)) + .flatten() + .expect("collider should be a Capsule"); // capsule_y: endpoints at (0, ±half_height, 0). assert!((segment.a.x).abs() < 1e-6); assert!((segment.a.z).abs() < 1e-6);