From 8b78900b7055274ad5e00c187f8971f13f34c066 Mon Sep 17 00:00:00 2001 From: martinjms Date: Sat, 2 May 2026 08:58:41 +0300 Subject: [PATCH 01/11] =?UTF-8?q?chore:=20disable=20claude-code-action=20w?= =?UTF-8?q?orkflow=20=E2=80=94=20local=20daemon=20handles=20dispatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/claude-issue.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/workflows/claude-issue.yml diff --git a/.github/workflows/claude-issue.yml b/.github/workflows/claude-issue.yml new file mode 100644 index 0000000..cfc1fc7 --- /dev/null +++ b/.github/workflows/claude-issue.yml @@ -0,0 +1,12 @@ +name: Claude on labeled issue +# DISABLED: Local daemon handles worker dispatch. CI would burn Claude Code API quota. +# Keeping the workflow definition for reference only. +on: + workflow_dispatch: + +jobs: + claude: + if: false + runs-on: ubuntu-latest + steps: + - run: echo "Worker CI disabled — local daemon handles this." From 131b439de35108cdebb57a1d8c31d4e3b46b9b8d Mon Sep 17 00:00:00 2001 From: martinjms Date: Sun, 3 May 2026 11:37:42 +0300 Subject: [PATCH 02/11] =?UTF-8?q?feat(arcane-infra):=20add=20RapierCluster?= =?UTF-8?q?Sim=20=E2=80=94=20Rapier-backed=20authoritative=20physics=20(v1?= =?UTF-8?q?)?= 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 03/11] 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 04/11] 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 05/11] 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 06/11] =?UTF-8?q?style:=20cargo=20fmt=20=E2=80=94=20break?= =?UTF-8?q?=20long=20entities.insert=20/=20assert!=20/=20closure-chain=20l?= =?UTF-8?q?ines?= 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); From 0f5cb7a89f9e0d428430b4425d72aaea878e1aab Mon Sep 17 00:00:00 2001 From: martinjms Date: Sun, 3 May 2026 17:35:51 +0300 Subject: [PATCH 07/11] docs(architecture): per-engine API discipline + cross-engine game logic + terrain MapProvider framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates entity-model.md with the architectural decisions from the 2026-05-03 sessions on cross-engine support and terrain handling. §7 Terrain — rewritten: - Static / voxel / procedural terrain shapes all supported through one per-engine MapProvider interface. - Game owns storage (object storage / SpacetimeDB voxel chunks / on-disk / procedural / hybrid) and authoring tool (engine editor / voxel editor / generator). Arcane owns the loading interface. - Voxel terrain content lives in SpacetimeDB; static mesh content in object storage; map manifest small in SpacetimeDB. §8 (new) Conceptual contract vs. per-engine API: - User-facing APIs are engine-native per plugin (UE C++, Unity C#, Godot, Rapier Rust). Wire format, manager / replication protocols, durable state schema invariant, and conceptual vocabulary are shared. Physics-property enums (BodyKind, ColliderShape, Material) are NOT promoted to a shared arcane-core; each plugin uses engine-native equivalents. - Reverses an earlier proposal to unify physics value types — that would produce four parallel re-implementations of the same enum across language boundaries with no benefit. §9 (new) Engine plugin pattern: - Engine-named base classes (AArcaneUnrealEntity / ArcaneUnityEntity / ArcaneGodotEntity) extending engine-native types. - Per-engine cluster runtime, MapProvider, in-tick imperative ops. - Wire-format byte-compatibility across all engines via shared protocol. §10 (new) Cross-engine entity migration: - Entities can migrate between cluster tiers running different engines. - Devs write per-engine game logic for each tier they support. - Migration is at cluster-process boundaries; durable state in SpacetimeDB is the lingua franca. - Cross-engine consistency for game rules (damage formulas, drop tables) lives in SpacetimeDB reducers called from every engine plugin. - No in-process engine switching; "the function that runs physics for this engine" is the entire cluster binary written in that engine's language. Refs #117, #118, #119, #122, #123, #124. Co-Authored-By: Claude Opus 4.7 --- docs/architecture/entity-model.md | 135 +++++++++++++++++++++++++++--- 1 file changed, 122 insertions(+), 13 deletions(-) diff --git a/docs/architecture/entity-model.md b/docs/architecture/entity-model.md index 3df3f41..1b5557b 100644 --- a/docs/architecture/entity-model.md +++ b/docs/architecture/entity-model.md @@ -95,26 +95,135 @@ This is the same kind of reasoning the manager already does for player movement --- -## 7. Terrain is NOT entities +## 7. Terrain — game owns storage and authoring; Arcane owns the loading interface -This is the only thing in the world that's *not* an entity: +Terrain is the only thing in the world that's *not* an entity. Three terrain shapes need to work in Arcane, all through the same interface: -| Aspect | Entity | Terrain | +| Terrain shape | Storage | Mutability | Example games | +|---|---|---|---| +| **Static / mesh** | Object storage (S3, CDN, on-disk asset bundle) | Read-only at runtime | UE level, Unity scene, glTF import | +| **Voxel** | **SpacetimeDB** (voxel grid is durable state — every block edit persists) | Mutable per-block, durable across sessions | Minecraft, Valheim, Astroneer | +| **Procedural / hybrid** | Seed in SpacetimeDB; geometry generated on demand; modifications stored as durable diffs | Effectively mutable via overrides | No Man's Sky, procedural sandboxes | + +If a game has destructible terrain that's purely entity-flavored (a wall a player can knock down), that's still an entity, not a terrain edit. Voxel terrain is genuinely terrain — it has no per-block entity_id, no per-block durable rows for entity-style state. Voxel chunks ARE durable rows, just at chunk granularity, not block granularity. + +### Arcane provides — the MapProvider interface + +The cluster runtime owns chunk loading. The game implements a `MapProvider` (per-engine plugin name) that the runtime calls: + +```rust +// Rapier-Rust shape; UE/Unity/Godot have parallel APIs in their native languages. +pub trait RapierMapProvider: Send + Sync { + /// Compute which chunks need to be loaded given the cluster's currently + /// owned entity positions. Pure function of input; called per cluster + /// tick (or on entity arrival/departure events). + fn chunks_in_range(&self, entity_positions: &[Vec3]) -> Vec; + + /// Fetch the collision geometry for a chunk. Implementation reads from + /// wherever the game stores it (SpacetimeDB voxel chunks, object storage + /// mesh bundles, embedded assets, procedural generators) — game's choice. + fn load_chunk(&self, chunk_id: ChunkId) -> Result; +} + +pub enum ChunkCollision { + TriMesh { vertices: Vec, indices: Vec<[u32; 3]> }, + HeightField { width: usize, height: usize, samples: Vec }, + // Voxel games typically convert to TriMesh via greedy meshing + // before returning, or expose per-block boxes if blocks are sparse. +} +``` + +Per the per-engine API discipline, this is one of many parallel APIs — the UE plugin defines `IArcaneUnrealMapProvider` returning UE-native collision data; Unity does the same with Unity-native types; etc. Different language, same conceptual contract. + +### Arcane does NOT provide + +- A map asset format. Game decides. +- An authoring tool. Game uses its engine's editor (UE, Unity, Godot, Blender, custom) or generates procedurally. +- A default storage backend. Game picks SpacetimeDB / object storage / on-disk / procedural / hybrid. +- Voxel-specific or mesh-specific support. The interface is uniform; the implementation differs per game. + +### Where things live (typical layout) + +| Data | Storage | +|---|---| +| Static map content (mesh files, prebaked geometry) | Object storage / asset bundle / on-disk — game's choice | +| Voxel terrain content | SpacetimeDB (durable, mutable, chunk granularity) | +| Map manifest (chunk catalog, version pointers) | SpacetimeDB row(s) — small, always available, used by cluster startup | +| Mutable per-chunk state (destruction events on a mesh chunk, voxel diffs) | SpacetimeDB rows tied to `chunk_id` | +| Per-chunk **entities** (placed structures, drops) | SpacetimeDB — already entities, already durable | + +**Game developers never insert terrain geometry into physics by hand at runtime.** They author the map (in their engine's editor or as voxel data); they implement the `MapProvider`; the cluster runtime calls it. See [issue #119](https://github.com/brainy-bots/arcane/issues/119). + +--- + +## 8. Conceptual contract vs. per-engine API + +This document defines **conceptual contracts**: what an entity is, the body-kind taxonomy, the affinity-vs-spatial binding distinction, the terrain-vs-entities split. These are vocabulary and mental model, not a code library. + +**The user-facing API for declaring entity properties is per-engine.** Arcane has multiple engine plugins (Rapier-Rust, UE, Unity, Godot — current and future). Each plugin exposes its own engine-native API for game developers, written in the engine's language and matching the engine's idioms. + +| Engine | User-facing API for entities | Body-kind expression | |---|---|---| -| 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 | +| **Rapier (Rust)** | Implement the `RapierClusterSimulation` trait; per-entity hooks return Rust enums | `RapierBodyKind` enum | +| **Unreal (C++)** | Subclass `AArcaneUnrealEntity` (extends `AActor`); plugin reads UE's native properties | `EComponentMobility` (UE-native) — **not** mirrored as a separate Arcane enum | +| **Unity (C#)** | Add `ArcaneUnityEntity` `MonoBehaviour` to a `GameObject`; plugin reads Unity's native properties | `Rigidbody.bodyType` + `Rigidbody.isKinematic` (Unity-native) | +| **Godot (GDScript / C#)** | Subclass `ArcaneGodotEntity` (Node3D base); plugin reads Godot's native node class | Choice of body class (`RigidBody3D` / `StaticBody3D` / `AnimatableBody3D` / `Area3D`) — Godot-native | -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. +**There is no shared Arcane `BodyKind` enum across engines.** The conceptual taxonomy (Dynamic / KinematicPositionBased / KinematicVelocityBased / Fixed) is documented here as vocabulary; each plugin uses its own engine-native equivalent. -**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. +The same applies to colliders, materials, collision groups, sensor flags, joint specs, and contact events. **Each engine plugin has its own value types in its own language.** Rust plugins use Rust enums; UE plugins use UENUM / USTRUCT; Unity uses C# classes; Godot uses GDScript dictionaries or C# classes. + +### What IS engine-neutral and shared + +- **Wire format types** (`EntityStateEntry`, `EntityStateDelta`, postcard / arcane-wire bytes) — every cluster speaks the same protocol. +- **Manager / replication protocols** — HTTP join, Redis pub/sub channel layout, neighbor delta semantics. +- **Durable state schema invariant** — every entity has a SpacetimeDB row. +- **Conceptual vocabulary** — this document. +- **Industry-standard term cross-references** — to make cross-engine and cross-team conversations productive (§9 below). + +### Why per-engine, not shared + +- Different languages (C++, C#, GDScript, Rust). Code-level type sharing is impossible across language boundaries; "shared types" become four parallel re-implementations of the same enum. +- Different idioms. UE devs hate writing un-UE-like code; same for every engine community. +- Game devs already think in their engine's vocabulary. Layering a parallel Arcane vocabulary on top is friction without benefit. +- Cross-engine consistency that *matters* (wire format, manager protocol, durable state) is enforced where it must be — at the protocol layer. + +--- + +## 9. Engine plugin pattern + +The canonical shape of an Arcane engine plugin: + +1. **Engine-native base class or interface** for game-side entities, named with the `Arcane{Engine}Entity` convention. Extends or implements engine-native types so the dev's code looks engine-native. +2. **Per-engine cluster runtime** that hosts the simulation tick and dispatches to the user's game logic. Listens to manager, publishes to Redis, broadcasts to WS clients — all in the engine's native language. +3. **Per-engine `MapProvider`** that the game implements for terrain loading. +4. **Per-engine in-tick imperative ops** (apply impulse, raycast, etc.) — engine-native types as inputs and outputs, but always **entity-keyed** never engine-handle-keyed (preserves the wire-format invariant). +5. **Wire-format byte-compatibility** with every other engine plugin. Reads / writes the exact same `EntityStateDelta` bytes that Rust clusters do. + +For game devs targeting multiple engines (e.g., a UE-cluster premium tier and a Rapier-cluster mid tier of the same game), this means writing **N parallel game-logic codebases**, one per engine plugin. Each is engine-native, idiomatic, and uses that engine's full feature set. **Cross-engine consistency for game rules** (damage, drops, currency, anything that must be transactionally consistent) **lives in SpacetimeDB reducers** — called from every engine plugin's cluster binary, ensuring the rules are guaranteed identical. + +The platform doesn't try to auto-port code. Devs choose how many tiers to support and write logic appropriate to each. + +--- + +## 10. Cross-engine entity migration + +Entities can migrate between cluster tiers running different engines (per the heterogeneous-tier vision in `#33` and the dynamic migration epic in `#34`). The migration mechanism is at cluster-process boundaries: + +1. Source cluster releases authority over the entity. Its engine-native game logic stops ticking it. Its physics body for the entity is destroyed. +2. **Durable state in SpacetimeDB is the source of truth**, has been throughout. Source writes a final state on release. +3. Target cluster takes authority. Reads durable state. **Target's engine-native game logic** (a different codebase, possibly a different language) starts ticking the entity. Target's physics body is spawned at the entity's current position. +4. The entity's position / velocity / replicated user_data continue to flow through the wire format unchanged. + +There is **no in-process engine switching**. The "function that runs physics for this engine" is the entire cluster binary written in that engine's language. The platform's job at migration time is the ownership-transfer protocol; the dev's job is to make sure their per-engine logic implementations preserve the entity's gameplay state across the swap. + +Migration timing is `#34`'s scope (dynamic tier migration as a platform primitive). Static tier placement (an entity is born in a tier, stays there) is the simpler v1 case. + +--- --- -## 8. Cross-references +## 11. Cross-references | Topic | Doc | |---|---| @@ -125,7 +234,7 @@ If a game has destructible terrain (a hole punched in a wall, dirt mined out), t --- -## 9. Industry-standard terminology cross-references +## 12. Industry-standard terminology cross-references When discussing Arcane's entity model with people from other engines: From 57df0fcd640f54235643d2e75a0891c310f4905e Mon Sep 17 00:00:00 2001 From: martinjms Date: Sun, 3 May 2026 17:56:55 +0300 Subject: [PATCH 08/11] docs(architecture): cross-cluster mutable terrain syncs via Redis (existing channel) + SpacetimeDB for durability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects an earlier framing that proposed SpacetimeDB pub/sub for cross-cluster voxel chunk synchronization. The right pattern follows the existing entity-replication mechanism: - Real-time replication between clusters → Redis pub/sub (existing channel). - Durable transactional storage → SpacetimeDB (per-chunk durable row). - State that needs both (voxel chunks, destructible-terrain edits, runtime modifications) → write to SpacetimeDB AND publish on Redis. Voxel chunks and destructible-terrain modifications are conceptually just another kind of cross-cluster game state that's both immediate and durable. Same mechanism Arcane already uses for EntityStateDelta. Updates entity-model.md §7 with the corrected Cross-cluster coordination subsection and the corrected Where-things-live storage table. Memory anchor: project_redis_vs_spacetimedb_split.md. Refs #119, #124. Co-Authored-By: Claude Opus 4.7 --- docs/architecture/entity-model.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/architecture/entity-model.md b/docs/architecture/entity-model.md index 1b5557b..cb4f1bf 100644 --- a/docs/architecture/entity-model.md +++ b/docs/architecture/entity-model.md @@ -147,11 +147,25 @@ Per the per-engine API discipline, this is one of many parallel APIs — the UE | Data | Storage | |---|---| | Static map content (mesh files, prebaked geometry) | Object storage / asset bundle / on-disk — game's choice | -| Voxel terrain content | SpacetimeDB (durable, mutable, chunk granularity) | +| Voxel terrain content | **SpacetimeDB** (durable per-chunk row) | | Map manifest (chunk catalog, version pointers) | SpacetimeDB row(s) — small, always available, used by cluster startup | -| Mutable per-chunk state (destruction events on a mesh chunk, voxel diffs) | SpacetimeDB rows tied to `chunk_id` | +| Mutable per-chunk state (destruction events, voxel diffs, runtime modifications) | **SpacetimeDB** for durability **+** **Redis pub/sub** for real-time cross-cluster sync | | Per-chunk **entities** (placed structures, drops) | SpacetimeDB — already entities, already durable | +### Cross-cluster coordination for mutable terrain + +Mutable terrain follows the same write-to-SpacetimeDB-and-publish-on-Redis pattern Arcane already uses for entity replication. Redis is the real-time replication channel between clusters; SpacetimeDB is the durable source of truth. Both are needed; they serve different purposes. + +When Cluster X modifies a chunk (player digs a tunnel, voxel block edit, destruction event on a mesh chunk): + +1. Cluster X writes the change to **SpacetimeDB** (durable). +2. Cluster X publishes the modification on **Redis** (real-time, on the existing replication channel). +3. Cluster Y has the same chunk loaded for its own owned entities — receives the Redis notification. +4. Cluster Y's MapProvider invalidates its local cache and reloads the chunk's collision geometry. +5. On any cluster restart: rehydrate from SpacetimeDB; Redis events from before the restart are already reflected in durable state. + +Voxel chunks are conceptually just another kind of cross-cluster game state that's both immediate (clusters need to see edits within a tick) and durable (edits persist across restarts). Same mechanism as `EntityStateDelta`. Don't propose "SpacetimeDB pub/sub" or "Redis as durable storage" — both are wrong by Arcane's design. + **Game developers never insert terrain geometry into physics by hand at runtime.** They author the map (in their engine's editor or as voxel data); they implement the `MapProvider`; the cluster runtime calls it. See [issue #119](https://github.com/brainy-bots/arcane/issues/119). --- From 9f59dc62d4894c885d00e6e6fd31355cc1e3eaf2 Mon Sep 17 00:00:00 2001 From: martinjms Date: Sun, 3 May 2026 21:30:39 +0300 Subject: [PATCH 09/11] =?UTF-8?q?docs(architecture):=20ADR-001=20=E2=80=94?= =?UTF-8?q?=20Rapier=20cluster=20integration=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codifies the architectural decisions made during the Rapier integration work (#117, #118, PR #123) for posterity. Closes the open ADR item in #8's acceptance criteria for the Rapier track. Decisions documented: - Composition over inheritance — RapierClusterSim IS-A ClusterSimulation that wraps a user ClusterSimulation (or RapierClusterSimulation). No new PhysicsBackend trait introduced. - In-process Rust library — no sidecar process, no FFI. Cargo feature rapier-cluster gates the optional rapier3d dependency. Vanilla builds pull zero rapier3d into the dep tree. - Single Mutex wrapper; user code never sees RigidBodySet directly. Entity-keyed in-tick ops only — no off-spine bodies. - Per-entity hooks called once at first-sight spawn; collider shape / material / body kind / collision groups / sensor are spawn-time decisions, not per-tick. - Velocity in / position out contract. User mutations to entity.position during on_tick are silently overwritten by Rapier's post-step output. - Contact events surface with one-tick delay (intent before output). Despawn-during-contact does NOT surface Stopped to the partner — partners detect via the entity map. - All public types are `#[non_exhaustive]` from day one. Alternatives considered + rejected: - Separate crate `arcane-physics-rapier` with new PhysicsBackend trait (rejected — Cargo feature flag achieves dependency isolation with less ceremony). - Sidecar process running Rapier (rejected — IPC overhead destroys per-tick budget for an in-process library). - Direct &mut RigidBodySet exposure to user code (rejected — off-spine bodies and cross-cluster joints would silently break replication invariants). - Engine-neutral physics types in arcane-core shared across backends (rejected — language barriers force per-plugin re-implementations anyway; documented in entity-model.md §8). Updates physics-backends-and-unreal.md §7 to point at the ADR rather than the earlier "separate crate per backend" framing, which the Rapier work refined. Updates docs/architecture/adr/README.md with an index of ADRs, marking ADR-001 as Accepted and ADR-002 (Unreal Cluster Node) as Pending per #124. Refs #8, #117, #118, #119, #122, #123, #124. Co-Authored-By: Claude Opus 4.7 --- .../001-rapier-cluster-integration-shape.md | 164 ++++++++++++++++++ docs/architecture/adr/README.md | 13 +- .../physics-backends-and-unreal.md | 11 +- 3 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 docs/architecture/adr/001-rapier-cluster-integration-shape.md diff --git a/docs/architecture/adr/001-rapier-cluster-integration-shape.md b/docs/architecture/adr/001-rapier-cluster-integration-shape.md new file mode 100644 index 0000000..872c0ca --- /dev/null +++ b/docs/architecture/adr/001-rapier-cluster-integration-shape.md @@ -0,0 +1,164 @@ +# ADR-001: Rapier cluster integration shape + +| | | +|---|---| +| **Status** | Accepted | +| **Date** | 2026-05-03 | +| **Implemented in** | PR [`brainy-bots/arcane#123`](https://github.com/brainy-bots/arcane/pull/123); commits `131b439`, `664671c`, `6a9c4fe`, `f91522d`, `f0938c9`, `0f5cb7a`, `57df0fc` | +| **Related epic** | [`#8`](https://github.com/brainy-bots/arcane/issues/8) — Cluster physics backends — Unreal (Chaos) first, multi-engine path | +| **Related issues** | [`#117`](https://github.com/brainy-bots/arcane/issues/117), [`#118`](https://github.com/brainy-bots/arcane/issues/118), [`#120`](https://github.com/brainy-bots/arcane/issues/120), [`#121`](https://github.com/brainy-bots/arcane/issues/121), [`#122`](https://github.com/brainy-bots/arcane/issues/122) | + +## Context + +Epic [`#8`](https://github.com/brainy-bots/arcane/issues/8) names Unreal/Chaos as the first concrete physics backend and Rapier as the second. Rapier landed first as the in-process Rust path because Rapier-in-Rust avoids FFI and lets the integration share `arcane-infra`'s networking primitives directly; the lessons inform the harder Unreal-native path. + +The decision space had three dimensions: + +1. **Trait shape** — should the cluster runtime introduce a new `PhysicsBackend` trait, or should physics be expressed through the existing `ClusterSimulation` hook? +2. **Process shape** — does Rapier run as a sidecar process / library / FFI-wrapped engine? +3. **State exposure to user code** — does game logic see Rapier's `RigidBodySet` directly, or does it work through entity-keyed methods? + +[`docs/architecture/physics-backends-and-unreal.md`](../physics-backends-and-unreal.md) §7 (Multi-backend path) anticipated a separate crate per backend. The Rapier work refined this: the actual decision is finer than "separate crate or not." + +## Decision + +### 1. Composition over inheritance — no new trait + +`RapierClusterSim` **is a `ClusterSimulation`** (the existing hook trait in `arcane-core`) that **wraps a user-provided `ClusterSimulation` (or the new `RapierClusterSimulation` for richer use cases)**. The cluster runtime calls a single `ClusterSimulation::on_tick(...)`; what differs by backend is which struct is passed. + +```text + pure-Rapier path +ClusterSimulation ───────────────── + ▲ RapierClusterSim::new(None, config) + │ + ├── (user impl) wrapped-user-sim path + │ ──────────────────── + └── RapierClusterSim ◄── wraps ── Arc (V1 user) + ◄── or ── Arc (V2 user) +``` + +**No new `PhysicsBackend` trait was introduced.** The `ClusterSimulation` trait is unchanged. `RapierClusterSimulation` is a *sibling* trait in `arcane-infra::rapier_cluster` (gated on the `rapier-cluster` Cargo feature) for users who want extended Rapier-specific surface (contact events, per-entity collider shapes); it is *not* a replacement. + +### 2. In-process Rust library — no sidecar, no FFI + +Rapier 0.32 is added as a Cargo `optional = true` dependency on `arcane-infra` behind feature `rapier-cluster`. The Rapier physics step runs inside `RapierClusterSim::on_tick`, in the same process and same thread as the cluster runtime. No IPC; no FFI; no separate binary. + +Vanilla `cargo build -p arcane-infra` pulls **zero** `rapier3d` into the dep tree. The feature gate is the standard Rust pattern for optional heavy deps; no separate crate / repo / build target required. + +### 3. Single `RapierState` mutex; user code never touches `RigidBodySet` directly + +`RapierState` (private) owns `RigidBodySet`, `ColliderSet`, `IslandManager`, etc., behind a `Mutex` (required because `ClusterSimulation::on_tick` takes `&self`). The lock is held *during* user `on_tick` (single-threaded by construction; cluster runner is single-threaded per cluster). + +User-facing API surfaces only **entity-keyed operations**: `apply_impulse(entity_id, impulse)`, `raycast(...)`, `set_translation(entity_id, position)` (planned in `#121`). Raw `&mut RigidBodySet` is never lent to user code. This preserves the wire-format invariant — no off-spine bodies that would lack `entity_id` and break replication. + +### 4. Per-entity hooks are spawn-time, called once per entity + +`collider_for(entry, config) -> RapierColliderShape` (and the planned spawn-time hooks in `#120`: `body_kind_for`, `material_for`, `is_sensor`, `collision_groups_for`) are called **exactly once at first-sight spawn**. After spawn, the body's shape and other per-entity properties are fixed. Mid-life shape change requires despawn-and-respawn — the same escape hatch as for any other Rapier-specific feature game devs may need. + +### 5. Velocity in / position out contract + +- `entity.velocity` is **intent-in.** Wrapper reads it at first-sight to seed the body's `linvel`; subsequent ticks read it as the per-tick velocity intent. +- `entity.position` is **output-only after first-sight spawn.** Wrapper reads `entity.position` exactly once when spawning; afterwards, Rapier owns position. User writes to `entity.position` during `on_tick` are silently overwritten by Rapier's post-step output. +- This contract is enforced by tests — see `position_writes_from_user_are_overwritten_by_rapier`. + +### 6. Contact events surface with one-tick delay + +Rapier emits `Started` / `Stopped` events during the physics step. These are buffered into `RapierState::pending_contact_events` and surfaced in **the next tick's** `RapierClusterTickContext::contact_events`. User logic always runs *before* physics each tick; one-tick delay is by design (intent before output). + +Contact events on despawned bodies are **not** surfaced to the contact partner — when an entity is removed via `pending_removals`, its collider is dropped from the reverse map before the post-step event drain. Partners detect the loss via the entity map (the entity is gone), not via a `Stopped` contact event. Documented + tested. + +### 7. Public API is `#[non_exhaustive]` from day one + +`RapierColliderShape`, `ContactEvent`, `RapierClusterTickContext`, and `RapierConfig` are all `#[non_exhaustive]` — adding fields or variants in future versions is not a SemVer break. Codifies the API-stability discipline before any external user touches the surface. + +## Alternatives considered + +### A. Separate crate `arcane-physics-rapier` with its own `PhysicsBackend` trait + +Rejected. Per `physics-backends-and-unreal.md` §7's earlier framing this looked clean, but in practice: + +- Forced an artificial split between `arcane-infra` (networking) and a Rapier-specific crate that would import from it anyway. +- A new `PhysicsBackend` trait would have duplicated `ClusterSimulation`'s responsibility (per-tick hook on the entity map). +- Cargo feature flag inside `arcane-infra` achieves the same dependency isolation with less ceremony. + +The original framing in `#8` is now updated to reflect the actual landed pattern. + +### B. Sidecar process — Rust cluster + Rapier-in-separate-binary + +Rejected. Rapier is a Rust library; running it in a separate process requires IPC for every per-tick state transfer (entity positions in, body positions out). At 20 Hz cluster tick × 1000 entities, that's 20,000 IPC messages per second per cluster. The latency overhead destroys the per-tick cost advantage. + +The integration shape from `#8` §"Integration shapes" #1 (Rust cluster + Unreal sidecar) was always meant for cases where physics genuinely cannot run in-process (Unreal/Chaos has lifecycle requirements that make in-process FFI hostile). Rapier doesn't have those requirements. + +### C. Direct `&mut RigidBodySet` exposure to user code + +Rejected. Tempting because it gives users the full Rapier API for free, but: + +- **Off-spine bodies** — user could insert bodies without `entity_id`s; those don't replicate; they're invisible to neighbor clusters; they die with the cluster process. Footgun: developers using off-spine bodies for gameplay state would silently break replication invariants. +- **Cross-cluster joints** — user could create joints between two entities in this cluster; if either entity migrates to another cluster, the joint becomes invalid. Without explicit lifecycle management, this is a silent correctness bug. +- **Wire-format invariant erosion** — direct handle access bypasses entity-id-keyed operations, breaking the assumption that everything in physics has a wire-format counterpart. + +The wrapped, entity-keyed API gives users every Rapier capability that game logic actually needs without these footguns. Capabilities not yet wrapped (e.g., compound colliders, mesh colliders, contact-force events) are tracked in `#122` and added as games need them. + +### D. Engine-neutral physics types in `arcane-core` shared across all backends + +Rejected (after a brief attempt). Promoting `RapierBodyKind`, `RapierColliderShape`, `RapierMaterial` etc. into engine-neutral types in `arcane-core` looked clean for documentation but: + +- UE/Unity/Godot plugins are written in different languages (C++, C#, GDScript). Rust types in `arcane-core` aren't reachable from those plugins; each plugin would have its own parallel re-implementation. +- Engine-native types (UE `Mobility`, Unity `Rigidbody.bodyType`, Godot subclass-per-kind) are what game devs already know. Layering a parallel Arcane vocabulary on top is friction without benefit. +- Cross-engine consistency that *matters* (wire format, manager protocol, durable state) is enforced where it must be — at the protocol layer, not the user-facing-API layer. + +The decision lives in [`entity-model.md`](../entity-model.md) §8 and [`project_per_engine_api_pattern.md`](../../../../.claude/projects/-mnt-e-code-pgp-demo/memory/project_per_engine_api_pattern.md) memory. Each plugin defines its own engine-native value types; only the wire format is shared. + +## Verification + +| Check | Result | +|---|---| +| Vanilla `cargo build -p arcane-infra` | Compiles; **0** `rapier3d` references in dep tree | +| `cargo build -p arcane-infra --features rapier-cluster --bins` | Compiles | +| Vanilla `cargo test -p arcane-infra` | 65 tests pass (no regression) | +| `cargo test -p arcane-infra --features rapier-cluster` | 104 tests pass — 68 lib (38 in `rapier_cluster::tests`) + 35 integration + 1 doctest | +| `cargo clippy --all-targets` (both feature configurations) | Silent | +| `cargo fmt --all -- --check` | Clean | +| Doctest in module-level `# Example` | Compiles | +| End-to-end smoke test against running Redis (`arcane-rapier-cluster` binary, ~1000 ticks) | Stable `tick_ms` ~0.07–0.08; WS connection accepted; zero parse failures / broadcast lag | + +The 38 `rapier_cluster::tests` cover every documented contract: + +- Lifecycle: spawn (first-sight position seeded), despawn (two paths: `pending_removals` + entity-map disappearance), respawn (same UUID), empty entity map. +- Multi-entity: independent advancement of differently-velocitied entities; 500-entity scale test. +- Dynamics: velocity passthrough vs. analytic, gravity vs. kinematic equation, mid-sim velocity change, monotonic velocity growth under gravity. +- User-sim composition: `None` user sim runs pure Rapier; user `on_tick` runs before physics with correct `tick`/`dt`/`game_actions`; user buff modulates velocity; user can request removal via `pending_removals`. +- Determinism / hand-off: same-input → same-output (in-process); state round-trips through despawn / respawn (cluster A → cluster B handoff scenario); contact events do not carry across hand-off. +- V2 contact events + colliders: overlap → Started; distant → no contacts; Cuboid honored; shape change after first-sight is ignored AND `collider_for` called exactly once per entity; one-tick delay; no duplicate Started for persistent overlap. +- Tier-1 contract pinning: Stopped event surfaces on separation; despawn-during-contact does NOT surface Stopped (documented behavior); Ball / Capsule shapes verified directly via `ColliderSet` inspection; multi-substep tick (`dt > FIXED_PHYSICS_DT`); slow-tick accumulator (`dt < FIXED_PHYSICS_DT`); contact resolution applies impulse to partner; `collider_for` invoked freshly on respawn. +- Tier-2 symmetry: V2 ctx propagates `game_actions` / `tick` / `dt`; V2 `pending_removals`; mixed Ball-vs-Cuboid contact; non-default gravity on arbitrary axis; V2 handoff contact-events reset; capsule axis is Y. + +## Consequences + +### Positive + +- Same `cluster_runner::run_cluster_loop` powers both vanilla and Rapier clusters; networking, replication, neighbor merge, persist are guaranteed identical (literally the same code). +- Vanilla builds remain Rapier-free; existing benchmarks unaffected. +- The wrapper composition pattern is the template for the next backend. The Unreal Cluster Node epic ([`#124`](https://github.com/brainy-bots/arcane/issues/124)) explicitly inherits the composition shape, the entity-keyed in-tick ops convention, and the spawn-time-hook pattern. + +### Negative / accepted trade-offs + +- **`Mutex` is held during user `on_tick` (V2 path).** Cluster runner is single-threaded per cluster anyway, so contention isn't an issue, but it does mean user code that re-enters Arcane through another path (calling another cluster, scheduling background work) must avoid acquiring the same lock. Documented. +- **Off-spine bodies are explicitly NOT supported.** Game developers wanting "purely visual" debris or particles cannot create un-replicated Rapier bodies. They must use entities (which replicate) or do effects client-side. Trade-off accepted in favor of the wire-format invariant. +- **Cross-cluster joints, multibody articulations, and other advanced Rapier features are not exposed yet.** Tracked in [`#122`](https://github.com/brainy-bots/arcane/issues/122) (gap inventory). Lit up as concrete games need them. +- **`f32` precision for positions** (Rapier 0.32 default). For worlds within ~10⁴ units of origin, sub-millimeter; far-from-origin coordinates lose precision in standard `f32` ways. Documented; switchable to Rapier's `f64` feature in a follow-up if needed. + +### Open follow-ups (tracked elsewhere) + +- [`#120`](https://github.com/brainy-bots/arcane/issues/120) — Spawn-time hooks (`body_kind_for`, `material_for`, `collision_groups_for`, `is_sensor`). +- [`#121`](https://github.com/brainy-bots/arcane/issues/121) — In-tick imperative ops (impulses, forces, raycasts, joints, teleport). +- [`#122`](https://github.com/brainy-bots/arcane/issues/122) — Gap inventory tracker; lists every Rapier capability with its status. +- [`#119`](https://github.com/brainy-bots/arcane/issues/119) — Terrain epic; the `RapierMapProvider` interface for chunk loading is part of this work. +- [`#124`](https://github.com/brainy-bots/arcane/issues/124) — Unreal Cluster Node epic, applying the lessons codified here. + +## References + +- [`docs/architecture/physics-backends-and-unreal.md`](../physics-backends-and-unreal.md) — Physics-tick contract, body-kind clarifications. +- [`docs/architecture/entity-model.md`](../entity-model.md) — Unified entity model, per-engine API discipline, terrain handling. +- [`docs/architecture/four-bucket-state-model.md`](../four-bucket-state-model.md) — Spine pose vs replicated user_data vs ephemeral local_data vs SpacetimeDB durable; durable-state-per-entity invariant. +- Memory anchors: `project_unified_entity_model.md`, `project_per_engine_api_pattern.md`, `project_redis_vs_spacetimedb_split.md`, `feedback_refresh_arcane_architecture_before_proposing.md`. diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index eb51040..0141d2d 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -1,7 +1,14 @@ # Architecture Decision Records (ADR) -Use this folder for **short, dated decisions** that implementers need on hand (e.g. “Unreal-native dedicated server for Chaos v1 demo,” UE version pin, tick sub-step policy). +Use this folder for **short, dated decisions** that implementers need on hand. Each ADR captures a concrete choice (integration shape, version pin, substep policy, plugin-distribution model, etc.), the alternatives considered, and the verification that the choice landed cleanly. -**Suggested name:** `001-unreal-physics-integration-shape.md` (increment the prefix per new ADR). +**Naming:** `NNN-short-slug.md` with `NNN` incrementing in filing order. -Link new ADRs from [physics-backends-and-unreal.md](../physics-backends-and-unreal.md) where they constrain that integration path. +Link new ADRs from [physics-backends-and-unreal.md](../physics-backends-and-unreal.md) and any other architecture doc whose path the ADR constrains. + +## Index + +| # | Date | Title | Status | +|---|---|---|---| +| [001](001-rapier-cluster-integration-shape.md) | 2026-05-03 | Rapier cluster integration shape — composition over inheritance; in-process Rust; entity-keyed user API; spawn-time hooks once per entity | Accepted | +| 002 | TBD | Unreal cluster integration shape — UE-native dedicated server with Chaos; plugin distribution; UE version pin; networking implementation (C++ native vs FFI shim) | Pending — required by [`#124`](https://github.com/brainy-bots/arcane/issues/124) | diff --git a/docs/architecture/physics-backends-and-unreal.md b/docs/architecture/physics-backends-and-unreal.md index 8d071d3..749aaf7 100644 --- a/docs/architecture/physics-backends-and-unreal.md +++ b/docs/architecture/physics-backends-and-unreal.md @@ -85,9 +85,14 @@ Maintain a **bidirectional map** in the integration layer: ## 7. Multi-backend path (second engine) -- Add a **separate** crate or binary (e.g. `my-game-physics-rapier`) that implements `ClusterSimulation` for Rapier **without** pulling Rapier into `arcane-core`. -- **Selection:** which implementation is passed into `run_cluster_loop` (Rust path) or which module runs (Unreal path) is a **build-time or packaging** choice, not a runtime plugin registry in v1. -- Optional later: feature flags on a game binary that select `Arc`. +The Rapier (Rust) backend has landed and is documented in [ADR-001](adr/001-rapier-cluster-integration-shape.md) — composition over inheritance, in-process Rust, single Cargo feature flag, no separate crate. The decisions are captured there: + +- **No new `PhysicsBackend` trait.** `RapierClusterSim` is itself a `ClusterSimulation` impl that wraps a user `ClusterSimulation` (or, in the V2 path, a sibling `RapierClusterSimulation`). +- **Selection** is build-time (Cargo features) and construction-time (which `Arc` is passed to `run_cluster_loop`); no runtime plugin registry. +- **Rapier as `optional = true` Cargo dep on `arcane-infra` behind feature `rapier-cluster`.** Vanilla builds pull zero `rapier3d`. No separate crate needed; the feature-flag pattern is sufficient. +- **Per-engine API discipline:** Rapier-specific types (`RapierColliderShape`, `RapierBodyKind`, `RapierMaterial`) stay in `arcane-infra::rapier_cluster`. They are **not** promoted to engine-neutral `arcane-core` types — see [`entity-model.md` §8](entity-model.md) for why. + +The Unreal/Chaos backend will follow the same composition pattern but with engine-native concerns (UE-native types, World Partition integration, Y↔Z axis swap, ×100 unit scale at the wire boundary). [`#124`](https://github.com/brainy-bots/arcane/issues/124) is the implementation epic; ADR-002 (pending) will capture the Unreal-side decisions. --- From 4ebe4252c887795fa72d750dc61ec9fa42390dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Mingo=20Suarez?= Date: Mon, 4 May 2026 03:22:43 +0300 Subject: [PATCH 10/11] feat(rapier-cluster): per-entity body kind / material / filtering / sensor hooks (#120) Refs #120 --- crates/arcane-infra/src/lib.rs | 10 +- crates/arcane-infra/src/rapier_cluster.rs | 818 +++++++++++++++++++++- 2 files changed, 818 insertions(+), 10 deletions(-) diff --git a/crates/arcane-infra/src/lib.rs b/crates/arcane-infra/src/lib.rs index 9c7321c..f75adb3 100644 --- a/crates/arcane-infra/src/lib.rs +++ b/crates/arcane-infra/src/lib.rs @@ -47,6 +47,12 @@ pub use rpc_handler::RpcHandler; #[cfg(feature = "rapier-cluster")] pub use rapier_cluster::{ - ContactEvent, RapierClusterSim, RapierClusterSimulation, RapierClusterTickContext, - RapierColliderShape, RapierConfig, + ContactEvent, RapierBodyKind, RapierClusterSim, RapierClusterSimulation, + RapierClusterTickContext, RapierColliderShape, RapierCollisionGroups, RapierConfig, + RapierMaterial, }; + +// Re-export Rapier's `Group` so users of `RapierCollisionGroups` can construct +// memberships/filter values without depending on rapier3d directly. +#[cfg(feature = "rapier-cluster")] +pub use rapier3d::geometry::Group; diff --git a/crates/arcane-infra/src/rapier_cluster.rs b/crates/arcane-infra/src/rapier_cluster.rs index 374cb14..a0dca5c 100644 --- a/crates/arcane-infra/src/rapier_cluster.rs +++ b/crates/arcane-infra/src/rapier_cluster.rs @@ -20,6 +20,47 @@ //! Shape is fixed at first-sight spawn; later `collider_for` returns are ignored //! for already-spawned entities (despawn-and-respawn to change shape). //! +//! # Per-entity spawn-time hooks +//! +//! Beyond `collider_for`, [`RapierClusterSimulation`] exposes four more hooks +//! that customize the rigid body / collider attached at first-sight spawn: +//! +//! - [`RapierClusterSimulation::body_kind_for`] — Dynamic / KinematicPositionBased +//! / KinematicVelocityBased / Fixed. Default `Dynamic`. +//! - [`RapierClusterSimulation::material_for`] — friction / restitution / density. +//! Default zero-friction, zero-restitution, unit-density. +//! - [`RapierClusterSimulation::collision_groups_for`] — `memberships` + `filter` +//! bitsets following Rapier's `InteractionGroups` semantics. Default +//! "everything collides with everything." +//! - [`RapierClusterSimulation::is_sensor`] — sensor colliders fire contact +//! events without producing physical pushback. Default `false`. +//! +//! All five hooks (these four plus `collider_for`) are called exactly once per +//! entity, at first-sight spawn. Subsequent return-value changes are ignored +//! for already-spawned bodies — despawn and respawn to change them. +//! +//! ## Subclass-style vs property-value-style +//! +//! Per [`docs/architecture/entity-model.md`](https://github.com/brainy-bots/arcane/blob/main/docs/architecture/entity-model.md) +//! §5, two patterns are equally valid for organizing per-entity hook returns: +//! +//! - **Property-value-style** — one [`RapierClusterSimulation`] impl matches +//! on a kind field in `entry.user_data` (or the entity's SpacetimeDB row) +//! and returns the right body kind / shape / material / groups per entity. +//! Cleaner for games with many or runtime-configurable kinds. +//! - **Subclass-style** — the game maintains its own per-entity routing (a +//! `HashMap>` etc.) and the +//! [`RapierClusterSimulation`] impl dispatches into it. More ergonomic for +//! games with a small fixed catalog of entity kinds. +//! +//! Both patterns work — the hook signatures take `&EntityStateEntry` so either +//! style can read whatever the game stored to make the decision. +//! +//! **`Fixed` and clustering:** introducing `Fixed` here only changes +//! physics-side behavior (solver-skipped, only AABB tracked). Until the +//! clustering-binding epic lands, `Fixed` entities still migrate by PGP +//! affinity — they are not yet pinned to chunk ownership. +//! //! # Contact events //! //! [`RapierClusterSimulation::on_tick`] receives a [`RapierClusterTickContext`] @@ -64,6 +105,61 @@ //! //! // Pass `Some(physics)` as the simulation arg to `run_cluster_loop`. //! ``` +//! +//! Property-value-style impl that uses every spawn-time hook: +//! +//! ```no_run +//! use arcane_core::replication_channel::EntityStateEntry; +//! use arcane_infra::{ +//! Group, RapierBodyKind, RapierClusterSimulation, RapierClusterTickContext, +//! RapierColliderShape, RapierCollisionGroups, RapierConfig, RapierMaterial, +//! }; +//! +//! struct MyGame; +//! impl RapierClusterSimulation for MyGame { +//! fn on_tick(&self, _ctx: &mut RapierClusterTickContext<'_>) {} +//! +//! fn body_kind_for(&self, entry: &EntityStateEntry, _c: &RapierConfig) -> RapierBodyKind { +//! match entry.user_data.get("kind").and_then(|v| v.as_str()) { +//! Some("wall") | Some("item") => RapierBodyKind::Fixed, +//! Some("platform") => RapierBodyKind::KinematicPositionBased, +//! _ => RapierBodyKind::Dynamic, // players, projectiles, etc. +//! } +//! } +//! +//! fn collider_for(&self, entry: &EntityStateEntry, c: &RapierConfig) -> RapierColliderShape { +//! match entry.user_data.get("kind").and_then(|v| v.as_str()) { +//! Some("player") => RapierColliderShape::Capsule { half_height: 0.9, radius: 0.4 }, +//! Some("wall") => RapierColliderShape::Cuboid([5.0, 2.0, 0.5]), +//! _ => RapierColliderShape::Ball(c.default_body_radius), +//! } +//! } +//! +//! fn material_for(&self, entry: &EntityStateEntry, _c: &RapierConfig) -> RapierMaterial { +//! match entry.user_data.get("surface").and_then(|v| v.as_str()) { +//! Some("ice") => RapierMaterial::new(0.05, 0.0, 1.0), +//! Some("rubber") => RapierMaterial::new(0.9, 0.8, 1.0), +//! _ => RapierMaterial::default(), +//! } +//! } +//! +//! fn collision_groups_for( +//! &self, +//! entry: &EntityStateEntry, +//! _c: &RapierConfig, +//! ) -> RapierCollisionGroups { +//! // Projectiles don't collide with the entity that fired them, etc. +//! match entry.user_data.get("kind").and_then(|v| v.as_str()) { +//! Some("projectile") => RapierCollisionGroups::new(Group::GROUP_2, Group::GROUP_1), +//! _ => RapierCollisionGroups::default(), +//! } +//! } +//! +//! fn is_sensor(&self, entry: &EntityStateEntry, _c: &RapierConfig) -> bool { +//! entry.user_data.get("kind").and_then(|v| v.as_str()) == Some("trigger_zone") +//! } +//! } +//! ``` use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; @@ -141,7 +237,112 @@ pub enum RapierColliderShape { Cuboid([f32; 3]), } -fn build_collider(shape: RapierColliderShape) -> Collider { +/// Physics body kind for an entity. Resolved at first-sight spawn via +/// [`RapierClusterSimulation::body_kind_for`]; subsequent calls are ignored +/// for already-spawned entities (despawn-and-respawn to change body kind). +/// +/// See [`docs/architecture/entity-model.md`](https://github.com/brainy-bots/arcane/blob/main/docs/architecture/entity-model.md) +/// §4 for the canonical taxonomy and per-kind use cases. +/// +/// **Note on `Fixed` and clustering:** introducing `Fixed` here only changes +/// physics-side behavior (solver-skipped, only AABB tracked in broadphase). +/// Until the (unfiled) clustering-binding epic lands, `Fixed` entities still +/// migrate by PGP affinity — they are not yet pinned to chunk ownership. +/// +/// `#[non_exhaustive]` so adding kinds in future versions isn't a SemVer break. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub enum RapierBodyKind { + /// Full physics simulation: forces, gravity, contacts all apply. Default. + #[default] + Dynamic, + /// Position is controlled by game logic; physics doesn't apply forces. + /// Used for moving platforms, elevators, custom-locomotion characters. + KinematicPositionBased, + /// Velocity is controlled by game logic; physics integrates that velocity + /// into position but doesn't add forces. Mid-ground between Dynamic and + /// KinematicPositionBased. + KinematicVelocityBased, + /// Solver-skipped; only AABB tracking in broadphase. Used for walls, + /// permanent fixtures, placed structures. + Fixed, +} + +/// Per-entity physics material — friction, restitution (bounciness), density +/// (drives mass derivation from collider volume). Resolved at first-sight +/// spawn via [`RapierClusterSimulation::material_for`]. +/// +/// Defaults are zero-friction, zero-restitution, unit-density — matches the +/// crate's "benchmark parity" stance (no surprising deceleration / bounce +/// out of the box). +/// +/// `#[non_exhaustive]` so adding fields (e.g. anisotropic friction) in future +/// versions isn't a SemVer break. +#[derive(Clone, Copy, Debug, PartialEq)] +#[non_exhaustive] +pub struct RapierMaterial { + pub friction: f32, + pub restitution: f32, + pub density: f32, +} + +impl RapierMaterial { + /// Build a material from explicit friction / restitution / density values. + pub const fn new(friction: f32, restitution: f32, density: f32) -> Self { + Self { + friction, + restitution, + density, + } + } +} + +impl Default for RapierMaterial { + fn default() -> Self { + Self::new(0.0, 0.0, 1.0) + } +} + +/// Collision filtering for an entity's collider — `memberships` declares +/// which group bits this collider belongs to; `filter` declares which group +/// bits it can collide with. Two colliders generate contacts iff +/// `(a.memberships & b.filter) != 0 && (b.memberships & a.filter) != 0`, +/// matching Rapier's `InteractionGroups` semantics. +/// +/// Default is "everything collides with everything" — `memberships = Group::ALL`, +/// `filter = Group::ALL`, equivalent to Rapier's `InteractionGroups::all()`. +/// +/// `#[non_exhaustive]` so adding fields (e.g. solver-only flags) in future +/// versions isn't a SemVer break. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct RapierCollisionGroups { + pub memberships: Group, + pub filter: Group, +} + +impl RapierCollisionGroups { + /// Build a groups value from explicit memberships and filter bits. + pub const fn new(memberships: Group, filter: Group) -> Self { + Self { + memberships, + filter, + } + } +} + +impl Default for RapierCollisionGroups { + fn default() -> Self { + Self::new(Group::ALL, Group::ALL) + } +} + +fn build_collider( + shape: RapierColliderShape, + material: RapierMaterial, + groups: RapierCollisionGroups, + is_sensor: bool, +) -> Collider { let builder = match shape { RapierColliderShape::Ball(radius) => ColliderBuilder::ball(radius), RapierColliderShape::Capsule { @@ -151,6 +352,15 @@ fn build_collider(shape: RapierColliderShape) -> Collider { RapierColliderShape::Cuboid(he) => ColliderBuilder::cuboid(he[0], he[1], he[2]), }; builder + .friction(material.friction) + .restitution(material.restitution) + .density(material.density) + .collision_groups(InteractionGroups::new( + groups.memberships, + groups.filter, + InteractionTestMode::And, + )) + .sensor(is_sensor) .active_events(ActiveEvents::COLLISION_EVENTS) .build() } @@ -214,6 +424,55 @@ pub trait RapierClusterSimulation: Send + Sync { ) -> RapierColliderShape { RapierColliderShape::Ball(config.default_body_radius) } + + /// Declare the rigid-body kind (Dynamic / KinematicPositionBased / + /// KinematicVelocityBased / Fixed) for an entity at first-sight spawn. + /// Default returns [`RapierBodyKind::Dynamic`]. Called exactly once per + /// entity; subsequent return-value changes are ignored for already-spawned + /// bodies. + fn body_kind_for(&self, _entry: &EntityStateEntry, _config: &RapierConfig) -> RapierBodyKind { + RapierBodyKind::Dynamic + } + + /// Declare the physics material (friction / restitution / density) for an + /// entity at first-sight spawn. Default is zero-friction, zero-restitution, + /// unit-density. Called exactly once per entity; subsequent return-value + /// changes are ignored for already-spawned bodies. + fn material_for(&self, _entry: &EntityStateEntry, _config: &RapierConfig) -> RapierMaterial { + RapierMaterial::default() + } + + /// Declare the collision-filter groups (memberships + filter) for an + /// entity's collider at first-sight spawn. Default is "everything collides + /// with everything" — `memberships = Group::ALL`, `filter = Group::ALL`. + /// Called exactly once per entity; subsequent return-value changes are + /// ignored for already-spawned bodies. + fn collision_groups_for( + &self, + _entry: &EntityStateEntry, + _config: &RapierConfig, + ) -> RapierCollisionGroups { + RapierCollisionGroups::default() + } + + /// Declare whether the entity's collider is a sensor (fires contact events + /// without producing physical pushback). Default is `false`. Called + /// exactly once per entity; subsequent return-value changes are ignored + /// for already-spawned bodies. + fn is_sensor(&self, _entry: &EntityStateEntry, _config: &RapierConfig) -> bool { + false + } +} + +/// Internal bundle of per-entity first-sight spawn parameters. Keeps +/// [`RapierState::spawn`]'s signature small and gives us one place to extend +/// when adding future spawn-time hooks. +struct SpawnParams { + shape: RapierColliderShape, + body_kind: RapierBodyKind, + material: RapierMaterial, + groups: RapierCollisionGroups, + is_sensor: bool, } struct RapierState { @@ -317,16 +576,29 @@ impl RapierState { &mut self, entity_id: Uuid, entry: &EntityStateEntry, - shape: RapierColliderShape, + params: SpawnParams, ) -> RigidBodyHandle { - let body = RigidBodyBuilder::dynamic() + let builder = match params.body_kind { + RapierBodyKind::Dynamic => RigidBodyBuilder::dynamic(), + RapierBodyKind::KinematicPositionBased => RigidBodyBuilder::kinematic_position_based(), + RapierBodyKind::KinematicVelocityBased => RigidBodyBuilder::kinematic_velocity_based(), + RapierBodyKind::Fixed => RigidBodyBuilder::fixed(), + }; + let body = builder .translation(to_rapier(entry.position)) .linvel(to_rapier(entry.velocity)) .build(); let body_handle = self.bodies.insert(body); - let collider_handle = - self.colliders - .insert_with_parent(build_collider(shape), body_handle, &mut self.bodies); + let collider_handle = self.colliders.insert_with_parent( + build_collider( + params.shape, + params.material, + params.groups, + params.is_sensor, + ), + body_handle, + &mut self.bodies, + ); self.handles.insert(entity_id, body_handle); self.collider_to_entity.insert(collider_handle, entity_id); body_handle @@ -507,6 +779,34 @@ impl RapierClusterSim { _ => RapierColliderShape::Ball(self.config.default_body_radius), } } + + fn body_kind_for(&self, entry: &EntityStateEntry) -> RapierBodyKind { + match &self.backend { + Backend::Rapier(sim) => sim.body_kind_for(entry, &self.config), + _ => RapierBodyKind::Dynamic, + } + } + + fn material_for(&self, entry: &EntityStateEntry) -> RapierMaterial { + match &self.backend { + Backend::Rapier(sim) => sim.material_for(entry, &self.config), + _ => RapierMaterial::default(), + } + } + + fn collision_groups_for(&self, entry: &EntityStateEntry) -> RapierCollisionGroups { + match &self.backend { + Backend::Rapier(sim) => sim.collision_groups_for(entry, &self.config), + _ => RapierCollisionGroups::default(), + } + } + + fn is_sensor_for(&self, entry: &EntityStateEntry) -> bool { + match &self.backend { + Backend::Rapier(sim) => sim.is_sensor(entry, &self.config), + _ => false, + } + } } impl ClusterSimulation for RapierClusterSim { @@ -553,8 +853,14 @@ impl ClusterSimulation for RapierClusterSim { if state.handles.contains_key(id) { state.set_linvel(*id, entry.velocity); } else { - let shape = self.shape_for(entry); - state.spawn(*id, entry, shape); + let params = SpawnParams { + shape: self.shape_for(entry), + body_kind: self.body_kind_for(entry), + material: self.material_for(entry), + groups: self.collision_groups_for(entry), + is_sensor: self.is_sensor_for(entry), + }; + state.spawn(*id, entry, params); } } @@ -2097,4 +2403,500 @@ mod tests { y_extent ); } + + // ─── per-entity hooks (#120): body kind / material / groups / sensor ─────── + + /// Per-entity overrides for the `HookSim` test fixture below. `None` means + /// "use the trait default for this hook on this entity." + #[derive(Clone, Default)] + struct EntitySpec { + shape: Option, + body_kind: Option, + material: Option, + groups: Option, + is_sensor: Option, + } + + /// Generic test fixture exercising all five spawn-time hooks. Records + /// per-(hook, entity) call counts so tests can assert exact-once invariants + /// directly. Records contact events from the previous tick. + struct HookSim { + per_entity: Mutex>, + contact_events: Mutex>, + counts: Mutex>, + } + + impl HookSim { + fn new() -> Arc { + Arc::new(Self { + per_entity: Mutex::new(HashMap::new()), + contact_events: Mutex::new(Vec::new()), + counts: Mutex::new(HashMap::new()), + }) + } + + fn set(&self, id: Uuid, spec: EntitySpec) { + self.per_entity.lock().unwrap().insert(id, spec); + } + + fn count(&self, hook: &'static str, id: Uuid) -> u64 { + *self.counts.lock().unwrap().get(&(hook, id)).unwrap_or(&0) + } + + fn snapshot_events(&self) -> Vec { + self.contact_events.lock().unwrap().clone() + } + + fn bump(&self, hook: &'static str, id: Uuid) { + *self.counts.lock().unwrap().entry((hook, id)).or_insert(0) += 1; + } + + fn spec_for(&self, id: Uuid) -> EntitySpec { + self.per_entity + .lock() + .unwrap() + .get(&id) + .cloned() + .unwrap_or_default() + } + } + + impl RapierClusterSimulation for HookSim { + fn on_tick(&self, ctx: &mut RapierClusterTickContext<'_>) { + self.contact_events + .lock() + .unwrap() + .extend_from_slice(ctx.contact_events); + } + + fn collider_for( + &self, + entry: &EntityStateEntry, + config: &RapierConfig, + ) -> RapierColliderShape { + self.bump("collider_for", entry.entity_id); + self.spec_for(entry.entity_id) + .shape + .unwrap_or(RapierColliderShape::Ball(config.default_body_radius)) + } + + fn body_kind_for(&self, entry: &EntityStateEntry, _: &RapierConfig) -> RapierBodyKind { + self.bump("body_kind_for", entry.entity_id); + self.spec_for(entry.entity_id).body_kind.unwrap_or_default() + } + + fn material_for(&self, entry: &EntityStateEntry, _: &RapierConfig) -> RapierMaterial { + self.bump("material_for", entry.entity_id); + self.spec_for(entry.entity_id).material.unwrap_or_default() + } + + fn collision_groups_for( + &self, + entry: &EntityStateEntry, + _: &RapierConfig, + ) -> RapierCollisionGroups { + self.bump("collision_groups_for", entry.entity_id); + self.spec_for(entry.entity_id).groups.unwrap_or_default() + } + + fn is_sensor(&self, entry: &EntityStateEntry, _: &RapierConfig) -> bool { + self.bump("is_sensor", entry.entity_id); + self.spec_for(entry.entity_id).is_sensor.unwrap_or(false) + } + } + + /// **#120-T1**: a `Fixed` body must not move under gravity. Verifies + /// `body_kind_for` is honored: solver skips the body, position stays put. + #[test] + fn fixed_body_does_not_move_under_gravity() { + let sim_arc = HookSim::new(); + let id = Uuid::from_u128(1); + sim_arc.set( + id, + EntitySpec { + body_kind: Some(RapierBodyKind::Fixed), + ..Default::default() + }, + ); + let config = RapierConfig { + gravity: [0.0, -9.81, 0.0], + ..Default::default() + }; + let sim = RapierClusterSim::with_rapier_sim( + sim_arc.clone() as Arc, + config, + ); + + let start = Vec3::new(0.0, 5.0, 0.0); + let mut entities = HashMap::new(); + entities.insert(id, mk_entry(id, start, Vec3::new(0.0, 0.0, 0.0))); + + // 2 seconds under -9.81 — a Dynamic body would be at ~y = -14.6. + step_n(&sim, &mut entities, 40, CLUSTER_DT); + + let p = entities.get(&id).unwrap().position; + assert!( + close(p.y, start.y, 1e-6), + "Fixed body moved under gravity: y = {} (expected {})", + p.y, + start.y + ); + } + + /// **#120-T2**: a `KinematicPositionBased` body ignores forces. Like Fixed + /// it doesn't fall under gravity, but unlike Fixed its position is meant + /// to be game-controlled (Rapier just doesn't apply forces to it). + #[test] + fn kinematic_position_based_ignores_gravity() { + let sim_arc = HookSim::new(); + let id = Uuid::from_u128(1); + sim_arc.set( + id, + EntitySpec { + body_kind: Some(RapierBodyKind::KinematicPositionBased), + ..Default::default() + }, + ); + let config = RapierConfig { + gravity: [0.0, -9.81, 0.0], + ..Default::default() + }; + let sim = RapierClusterSim::with_rapier_sim( + sim_arc.clone() as Arc, + config, + ); + + let start = Vec3::new(0.0, 5.0, 0.0); + let mut entities = HashMap::new(); + entities.insert(id, mk_entry(id, start, Vec3::new(0.0, 0.0, 0.0))); + + step_n(&sim, &mut entities, 40, CLUSTER_DT); + + let p = entities.get(&id).unwrap().position; + assert!( + close(p.y, start.y, 1e-3), + "KinematicPositionBased body fell under gravity: y = {}", + p.y + ); + } + + /// **#120-T3**: `material_for` is honored — friction / restitution / density + /// land on the resulting collider. Structural test (direct collider read); + /// the dynamic effect is covered by the bounce test below. + #[test] + fn material_for_is_honored_on_collider() { + let sim_arc = HookSim::new(); + let id = Uuid::from_u128(1); + sim_arc.set( + id, + EntitySpec { + material: Some(RapierMaterial::new(0.42, 0.73, 5.5)), + ..Default::default() + }, + ); + let sim = RapierClusterSim::with_rapier_sim( + sim_arc.clone() as Arc, + RapierConfig::default(), + ); + + let mut entities = HashMap::new(); + 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 (friction, restitution, density) = + with_collider(&sim, id, |c| (c.friction(), c.restitution(), c.density())) + .expect("collider exists"); + assert!((friction - 0.42).abs() < 1e-5, "friction = {}", friction); + assert!( + (restitution - 0.73).abs() < 1e-5, + "restitution = {}", + restitution + ); + assert!((density - 5.5).abs() < 1e-5, "density = {}", density); + } + + /// **#120-T4**: high restitution produces a noticeably bouncier collision + /// than zero restitution. Drops a Dynamic ball onto a Fixed floor with + /// restitution=1.0; vertical velocity at the apex of the rebound should be + /// substantially higher than the same setup with restitution=0.0. + #[test] + fn high_restitution_bounces_higher_than_zero_restitution() { + fn peak_y_after_bounce(restitution: f32) -> f64 { + let sim_arc = HookSim::new(); + let ball = Uuid::from_u128(1); + let floor = Uuid::from_u128(2); + // Both bodies share the restitution; Rapier averages contact-pair + // material values (default `Average` rule), so setting it on both + // pins the effective contact restitution. + sim_arc.set( + ball, + EntitySpec { + shape: Some(RapierColliderShape::Ball(0.3)), + material: Some(RapierMaterial::new(0.0, restitution, 1.0)), + ..Default::default() + }, + ); + sim_arc.set( + floor, + EntitySpec { + shape: Some(RapierColliderShape::Cuboid([20.0, 0.25, 20.0])), + body_kind: Some(RapierBodyKind::Fixed), + material: Some(RapierMaterial::new(0.0, restitution, 1.0)), + ..Default::default() + }, + ); + let config = RapierConfig { + gravity: [0.0, -9.81, 0.0], + ..Default::default() + }; + let sim = RapierClusterSim::with_rapier_sim( + sim_arc.clone() as Arc, + config, + ); + + let mut entities = HashMap::new(); + entities.insert( + ball, + mk_entry(ball, Vec3::new(0.0, 3.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + entities.insert( + floor, + mk_entry(floor, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + + // Drop time ≈ √(2·3/9.81) ≈ 0.78 s ≈ 16 cluster ticks. After tick + // ~20 the ball has impacted; from there, track the rebound peak. + // Bouncy (r=1) rebounds toward y≈3; dead (r=0) plateaus at floor. + let mut peak_after_impact = f64::NEG_INFINITY; + for tick in 0..80 { + step_once(&sim, &mut entities, tick + 1, CLUSTER_DT); + if tick >= 20 { + let y = entities.get(&ball).unwrap().position.y; + if y > peak_after_impact { + peak_after_impact = y; + } + } + } + peak_after_impact + } + + let bouncy = peak_y_after_bounce(1.0); + let dead = peak_y_after_bounce(0.0); + // Bouncy rebounds to a meaningful height above where the dead ball + // came to rest. Generous margin (1.0 m) for substep losses. + assert!( + bouncy > dead + 1.0, + "bouncy post-impact peak {} must exceed dead post-impact peak {} by > 1.0", + bouncy, + dead + ); + } + + /// **#120-T5**: density change affects mass-derived collision response. + /// Inspect the body's mass after spawn — for a unit-radius ball with the + /// default density formula, doubling density doubles mass. + #[test] + fn density_changes_body_mass() { + fn mass_for_density(density: f32) -> f32 { + let sim_arc = HookSim::new(); + let id = Uuid::from_u128(1); + sim_arc.set( + id, + EntitySpec { + shape: Some(RapierColliderShape::Ball(1.0)), + material: Some(RapierMaterial::new(0.0, 0.0, density)), + ..Default::default() + }, + ); + let sim = RapierClusterSim::with_rapier_sim( + sim_arc.clone() as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + 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 h = *state.handles.get(&id).unwrap(); + state.bodies.get(h).unwrap().mass() + } + let m1 = mass_for_density(1.0); + let m2 = mass_for_density(2.0); + assert!( + (m2 / m1 - 2.0).abs() < 1e-3, + "doubling density should double mass: m1 = {}, m2 = {}", + m1, + m2 + ); + } + + /// **#120-T6**: collision groups filter contacts. Two overlapping bodies + /// in non-overlapping groups must not generate any contact events. Same + /// pair with default groups produces contacts (sanity-check baseline). + #[test] + fn non_overlapping_collision_groups_filter_contacts() { + // Group setup: A is in GROUP_1, filters only GROUP_1 (i.e. won't see B). + // B is in GROUP_2, filters only GROUP_2 (won't see A). + let sim_arc = HookSim::new(); + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + sim_arc.set( + a, + EntitySpec { + shape: Some(RapierColliderShape::Ball(0.5)), + groups: Some(RapierCollisionGroups::new(Group::GROUP_1, Group::GROUP_1)), + ..Default::default() + }, + ); + sim_arc.set( + b, + EntitySpec { + shape: Some(RapierColliderShape::Ball(0.5)), + groups: Some(RapierCollisionGroups::new(Group::GROUP_2, Group::GROUP_2)), + ..Default::default() + }, + ); + let sim = RapierClusterSim::with_rapier_sim( + sim_arc.clone() as Arc, + RapierConfig::default(), + ); + + let mut entities = HashMap::new(); + // Significant overlap — without filtering this would produce contacts. + 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, 5, CLUSTER_DT); + + let events = sim_arc.snapshot_events(); + assert!( + !events + .iter() + .any(|e| (e.entity_a == a && e.entity_b == b) + || (e.entity_a == b && e.entity_b == a)), + "non-overlapping groups should suppress contacts; got {:?}", + events + ); + } + + /// **#120-T7**: a sensor collider fires the contact event but does NOT + /// produce physical pushback on the partner body. Without filtering, two + /// overlapping balls would resolve apart; with one as a sensor, the + /// non-sensor ball stays at its starting position. + #[test] + fn sensor_fires_event_without_pushback() { + let sim_arc = HookSim::new(); + let trigger = Uuid::from_u128(1); + let body = Uuid::from_u128(2); + sim_arc.set( + trigger, + EntitySpec { + shape: Some(RapierColliderShape::Ball(0.5)), + body_kind: Some(RapierBodyKind::Fixed), + is_sensor: Some(true), + ..Default::default() + }, + ); + sim_arc.set( + body, + EntitySpec { + shape: Some(RapierColliderShape::Ball(0.5)), + ..Default::default() + }, + ); + let sim = RapierClusterSim::with_rapier_sim( + sim_arc.clone() as Arc, + RapierConfig::default(), + ); + + let mut entities = HashMap::new(); + let body_start = Vec3::new(0.4, 0.0, 0.0); + entities.insert( + trigger, + mk_entry(trigger, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + entities.insert(body, mk_entry(body, body_start, Vec3::new(0.0, 0.0, 0.0))); + + step_n(&sim, &mut entities, 5, CLUSTER_DT); + + // Contact event fired. + let events = sim_arc.snapshot_events(); + assert!( + events + .iter() + .any(|e| (e.entity_a == trigger && e.entity_b == body) + || (e.entity_a == body && e.entity_b == trigger)), + "sensor must still surface contact event; got {:?}", + events + ); + + // No pushback — body stayed at its start. + let p = entities.get(&body).unwrap().position; + assert!( + close(p.x, body_start.x, 1e-3), + "sensor produced pushback: x moved from {} to {}", + body_start.x, + p.x + ); + } + + /// **#120-T8**: every spawn-time hook is called exactly once per entity + /// at first-sight. Subsequent ticks do not re-invoke the hooks. + #[test] + fn all_hooks_called_exactly_once_per_entity() { + let sim_arc = HookSim::new(); + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + let sim = RapierClusterSim::with_rapier_sim( + sim_arc.clone() as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + // a spawns on tick 1; b spawns on tick 4 (later first-sight). + entities.insert( + a, + mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + step_n(&sim, &mut entities, 3, CLUSTER_DT); + + entities.insert( + b, + mk_entry(b, Vec3::new(50.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + step_n(&sim, &mut entities, 4, CLUSTER_DT); + + // Hook call counts: each of the five hooks must have been called + // exactly once for each of the two entities. + for hook in [ + "collider_for", + "body_kind_for", + "material_for", + "collision_groups_for", + "is_sensor", + ] { + assert_eq!( + sim_arc.count(hook, a), + 1, + "{hook} called {} times for entity a", + sim_arc.count(hook, a) + ); + assert_eq!( + sim_arc.count(hook, b), + 1, + "{hook} called {} times for entity b", + sim_arc.count(hook, b) + ); + } + } } From 12151670e0568226a25fba751c463187fb294bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Mingo=20Suarez?= Date: Mon, 4 May 2026 03:56:28 +0300 Subject: [PATCH 11/11] feat(rapier-cluster): in-tick imperative physics ops via PhysicsHandle (#121) Refs #121 --- crates/arcane-infra/src/lib.rs | 6 +- crates/arcane-infra/src/rapier_cluster.rs | 998 +++++++++++++++++++++- 2 files changed, 976 insertions(+), 28 deletions(-) diff --git a/crates/arcane-infra/src/lib.rs b/crates/arcane-infra/src/lib.rs index f75adb3..e874598 100644 --- a/crates/arcane-infra/src/lib.rs +++ b/crates/arcane-infra/src/lib.rs @@ -47,9 +47,9 @@ pub use rpc_handler::RpcHandler; #[cfg(feature = "rapier-cluster")] pub use rapier_cluster::{ - ContactEvent, RapierBodyKind, RapierClusterSim, RapierClusterSimulation, - RapierClusterTickContext, RapierColliderShape, RapierCollisionGroups, RapierConfig, - RapierMaterial, + ContactEvent, JointId, JointSpec, PhysicsHandle, RapierBodyKind, RapierClusterSim, + RapierClusterSimulation, RapierClusterTickContext, RapierColliderShape, RapierCollisionGroups, + RapierConfig, RapierMaterial, RaycastHit, }; // Re-export Rapier's `Group` so users of `RapierCollisionGroups` can construct diff --git a/crates/arcane-infra/src/rapier_cluster.rs b/crates/arcane-infra/src/rapier_cluster.rs index a0dca5c..3026a60 100644 --- a/crates/arcane-infra/src/rapier_cluster.rs +++ b/crates/arcane-infra/src/rapier_cluster.rs @@ -61,6 +61,29 @@ //! clustering-binding epic lands, `Fixed` entities still migrate by PGP //! affinity — they are not yet pinned to chunk ownership. //! +//! # In-tick imperative ops +//! +//! [`RapierClusterTickContext::physics`] exposes a [`PhysicsHandle`] that lets +//! `on_tick` mutate Rapier state and run synchronous queries. All operations +//! are entity-keyed (take `Uuid`, never raw Rapier handles). +//! +//! - **Forces / impulses:** `apply_impulse`, `apply_force`, `apply_torque_impulse`. +//! - **Direct overrides:** `set_translation` (teleport), `set_linvel`, `set_angvel`. +//! - **Reads:** `linvel`, `angvel`. +//! - **Sleep control:** `wake`, `sleep`. +//! - **Spatial queries:** `raycast`, `intersections_with_shape`. +//! - **Joints:** `create_joint` (Fixed / Revolute / Spherical / Prismatic) and +//! `remove_joint`. Joints are auto-removed when either connected entity +//! despawns. +//! +//! Per-op contracts (Fixed-body no-op, missing-id no-panic, set_linvel ↔ +//! per-tick velocity sync interaction) are documented on [`PhysicsHandle`]. +//! +//! ⚠️ **Lock window.** For the Rapier-aware path (`with_rapier_sim`) the +//! cluster's state lock is held for the duration of `on_tick`. The plain +//! `ClusterSimulation` path keeps the legacy "lock released during user code" +//! behavior — it has no `PhysicsHandle` to give it. +//! //! # Contact events //! //! [`RapierClusterSimulation::on_tick`] receives a [`RapierClusterTickContext`] @@ -160,6 +183,57 @@ //! } //! } //! ``` +//! +//! In-tick imperative ops (apply impulse on collision, hitscan raycast, +//! teleport on respawn, joint creation): +//! +//! ```no_run +//! use arcane_core::Vec3; +//! use arcane_infra::{ +//! JointSpec, RapierClusterSimulation, RapierClusterTickContext, RapierColliderShape, +//! }; +//! use uuid::Uuid; +//! +//! struct ActionGame { +//! player: Uuid, +//! barrel: Uuid, +//! } +//! impl RapierClusterSimulation for ActionGame { +//! fn on_tick(&self, ctx: &mut RapierClusterTickContext<'_>) { +//! // Hitscan: raycast from player forward, knock the hit entity back. +//! if let Some(player_pos) = ctx.entities.get(&self.player).map(|e| e.position) { +//! let dir = Vec3::new(0.0, 0.0, 1.0); +//! if let Some(hit) = ctx.physics.raycast(player_pos, dir, 50.0) { +//! ctx.physics.apply_impulse(hit.entity_id, Vec3::new(0.0, 0.0, 10.0)); +//! } +//! } +//! +//! // Explosion: every entity in 5m of the barrel takes a radial impulse. +//! if let Some(barrel_pos) = ctx.entities.get(&self.barrel).map(|e| e.position) { +//! let radius = RapierColliderShape::Ball(5.0); +//! for hit_id in ctx.physics.intersections_with_shape(&radius, barrel_pos) { +//! if hit_id == self.barrel { continue; } +//! if let Some(p) = ctx.entities.get(&hit_id).map(|e| e.position) { +//! let strength = 5.0; +//! let away = Vec3::new( +//! (p.x - barrel_pos.x) * strength, +//! (p.y - barrel_pos.y) * strength, +//! (p.z - barrel_pos.z) * strength, +//! ); +//! ctx.physics.apply_impulse(hit_id, away); +//! } +//! } +//! } +//! +//! // Teleport on contact-event: when player touches a "respawn pad", relocate. +//! for ev in ctx.contact_events.iter().filter(|e| e.started) { +//! if ev.entity_a == self.player { +//! ctx.physics.set_translation(self.player, Vec3::new(0.0, 5.0, 0.0)); +//! } +//! } +//! } +//! } +//! ``` use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; @@ -386,11 +460,83 @@ pub struct ContactEvent { pub started: bool, } +/// A hit returned by [`PhysicsHandle::raycast`]. Maps Rapier's collider hit +/// back to an entity id. +/// +/// `#[non_exhaustive]` so adding fields (e.g. `feature` for sub-shape index) +/// in future versions isn't a SemVer break. +#[derive(Clone, Copy, Debug, PartialEq)] +#[non_exhaustive] +pub struct RaycastHit { + /// The entity whose collider was hit. + pub entity_id: Uuid, + /// Time of impact along the ray, in units of the ray's direction length. + /// For a unit-length direction this is the world-space distance. + pub time_of_impact: f32, + /// World-space hit point. + pub point: Vec3, + /// Surface normal at the hit point (world space). + pub normal: Vec3, +} + +impl RaycastHit { + pub const fn new(entity_id: Uuid, time_of_impact: f32, point: Vec3, normal: Vec3) -> Self { + Self { + entity_id, + time_of_impact, + point, + normal, + } + } +} + +/// Joint shape for [`PhysicsHandle::create_joint`]. Anchors are in each +/// body's local space. Limits and motors are not exposed in this minimal +/// surface — add via Rapier directly if needed (`out` of scope for `#121`). +/// +/// `#[non_exhaustive]` so adding variants / fields in future versions isn't a +/// SemVer break. +#[derive(Clone, Copy, Debug, PartialEq)] +#[non_exhaustive] +pub enum JointSpec { + /// Rigidly attaches the two bodies — no relative motion. + Fixed { + local_anchor_a: Vec3, + local_anchor_b: Vec3, + }, + /// Allows rotation around `axis` only (1 DoF rotational). + Revolute { + local_anchor_a: Vec3, + local_anchor_b: Vec3, + /// Axis of rotation, expressed in body A's local frame. + axis: Vec3, + }, + /// Allows free rotation around the anchor (3 DoF rotational, ball-joint). + Spherical { + local_anchor_a: Vec3, + local_anchor_b: Vec3, + }, + /// Allows translation along `axis` only (1 DoF translational, slider). + Prismatic { + local_anchor_a: Vec3, + local_anchor_b: Vec3, + /// Axis of translation, expressed in body A's local frame. + axis: Vec3, + }, +} + +/// Opaque handle returned by [`PhysicsHandle::create_joint`]; pass back to +/// [`PhysicsHandle::remove_joint`]. Joints are auto-removed when either of +/// the connected entities despawns — calling `remove_joint` afterwards is a +/// safe no-op (returns `false`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct JointId(ImpulseJointHandle); + /// Tick context delivered to [`RapierClusterSimulation::on_tick`]. Mirrors -/// [`ClusterTickContext`] field-for-field plus Rapier-specific extensions. +/// [`ClusterTickContext`] field-for-field plus Rapier-specific extensions +/// (contact events, in-tick physics handle). /// -/// `#[non_exhaustive]` so future fields (e.g. raycast/query handles, physics -/// command queues) aren't a SemVer break for downstream impls. +/// `#[non_exhaustive]` so future fields aren't a SemVer break for downstream impls. #[non_exhaustive] pub struct RapierClusterTickContext<'a> { pub cluster_id: Uuid, @@ -402,6 +548,11 @@ pub struct RapierClusterTickContext<'a> { /// 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], + /// In-tick imperative physics ops — apply impulse/force/torque, teleport, + /// raycast, intersection queries, joint creation. See [`PhysicsHandle`] for + /// the full surface. Reads and mutations hit Rapier state synchronously + /// while the user's `on_tick` runs. + pub physics: PhysicsHandle<'a>, } /// Rapier-aware sibling of [`ClusterSimulation`]. Implement this trait and pass @@ -497,6 +648,11 @@ struct RapierState { /// 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, + /// Entities whose `linvel` was set imperatively this tick (via + /// `PhysicsHandle::set_linvel` or `apply_impulse`). The per-tick + /// `entity.velocity` → `body.linvel` sync skips these so the imperative + /// override sticks. Cleared at the start of every tick. + pending_imperative_linvel: HashSet, } /// Internal `EventHandler` impl that records collisions into a `Mutex`. @@ -569,6 +725,7 @@ impl RapierState { accumulator: 0.0, gravity, pending_contact_events: Vec::new(), + pending_imperative_linvel: HashSet::new(), } } @@ -708,6 +865,273 @@ impl RapierState { } } +/// In-tick imperative physics ops, exposed via [`RapierClusterTickContext::physics`]. +/// +/// Holds a mutable borrow of the Rapier state for the duration of the user's +/// `on_tick`; the cluster's state lock is held alongside. All operations are +/// **entity-keyed** — they take `Uuid`, never raw Rapier handles, preserving +/// Arcane's invariant that the entity map is the user's view into physics state. +/// +/// **Operations on missing entity ids** return `false` / `None` without panicking. +/// +/// **Operations on `Fixed` bodies** (`apply_impulse` / `apply_force` / +/// `apply_torque_impulse` / `set_linvel` / `set_angvel`) silently no-op and +/// return `false`. Gameplay code shouldn't have to query body kind before +/// applying force; the no-op is the contract. +/// +/// **`set_translation` for Dynamic bodies** teleports immediately. Can violate +/// contact constraints (a body landing inside another body); Rapier resolves +/// any new contacts at the next step. Documented behavior, not a bug. +/// +/// **`set_linvel` / `apply_impulse` and the per-tick velocity sync.** After +/// `on_tick`, the wrapper syncs `entity.velocity` → `body.linvel` for every +/// existing body so the declarative path keeps working. Calls that mutate +/// linvel imperatively (`set_linvel`, `apply_impulse`) mark the entity as +/// "imperatively touched" — the per-tick sync skips those entities so the +/// imperative override stays in effect for the upcoming physics step. +pub struct PhysicsHandle<'a> { + state: &'a mut RapierState, +} + +impl<'a> PhysicsHandle<'a> { + fn new(state: &'a mut RapierState) -> Self { + Self { state } + } + + fn body_mut(&mut self, entity_id: Uuid) -> Option<&mut RigidBody> { + let handle = *self.state.handles.get(&entity_id)?; + self.state.bodies.get_mut(handle) + } + + fn body(&self, entity_id: Uuid) -> Option<&RigidBody> { + let handle = *self.state.handles.get(&entity_id)?; + self.state.bodies.get(handle) + } + + /// Apply an instantaneous linear impulse to the entity's body. Updates + /// `body.linvel` by `impulse / mass` immediately. Marks the entity as + /// imperatively touched so the per-tick `entity.velocity` sync doesn't + /// clobber the change. + /// + /// Returns `false` if the entity has no body, or the body is `Fixed`. + pub fn apply_impulse(&mut self, entity_id: Uuid, impulse: Vec3) -> bool { + let Some(body) = self.body_mut(entity_id) else { + return false; + }; + if body.body_type() == RigidBodyType::Fixed { + return false; + } + body.apply_impulse(to_rapier(impulse), true); + self.state.pending_imperative_linvel.insert(entity_id); + true + } + + /// Add a continuous force, applied during the upcoming physics step. + /// Cleared by Rapier at the start of each step. + /// + /// Returns `false` if the entity has no body, or the body is `Fixed`. + pub fn apply_force(&mut self, entity_id: Uuid, force: Vec3) -> bool { + let Some(body) = self.body_mut(entity_id) else { + return false; + }; + if body.body_type() == RigidBodyType::Fixed { + return false; + } + body.add_force(to_rapier(force), true); + true + } + + /// Apply an instantaneous angular impulse (torque impulse). + /// + /// Returns `false` if the entity has no body, or the body is `Fixed`. + pub fn apply_torque_impulse(&mut self, entity_id: Uuid, torque: Vec3) -> bool { + let Some(body) = self.body_mut(entity_id) else { + return false; + }; + if body.body_type() == RigidBodyType::Fixed { + return false; + } + body.apply_torque_impulse(to_rapier(torque), true); + true + } + + /// Teleport the entity's body to `position`. The new translation is + /// reflected in `entity.position` after the tick (via `sync_outputs`). + /// May violate contact constraints; Rapier resolves at the next step. + /// + /// Returns `false` if the entity has no body. Allowed on `Fixed` bodies + /// (you can move walls), though clustering may not yet pin the new + /// chunk ownership (see clustering-binding epic). + pub fn set_translation(&mut self, entity_id: Uuid, position: Vec3) -> bool { + let Some(body) = self.body_mut(entity_id) else { + return false; + }; + body.set_translation(to_rapier(position), true); + true + } + + /// Override the entity's linear velocity directly, bypassing the per-tick + /// `entity.velocity` declarative path. Marks the entity as imperatively + /// touched so the override sticks. + /// + /// Returns `false` if the entity has no body, or the body is `Fixed`. + pub fn set_linvel(&mut self, entity_id: Uuid, linvel: Vec3) -> bool { + let Some(body) = self.body_mut(entity_id) else { + return false; + }; + if body.body_type() == RigidBodyType::Fixed { + return false; + } + body.set_linvel(to_rapier(linvel), true); + self.state.pending_imperative_linvel.insert(entity_id); + true + } + + /// Override the entity's angular velocity directly. + /// + /// Returns `false` if the entity has no body, or the body is `Fixed`. + pub fn set_angvel(&mut self, entity_id: Uuid, angvel: Vec3) -> bool { + let Some(body) = self.body_mut(entity_id) else { + return false; + }; + if body.body_type() == RigidBodyType::Fixed { + return false; + } + body.set_angvel(to_rapier(angvel), true); + true + } + + /// Read the entity's current linear velocity. Returns `None` if no body. + pub fn linvel(&self, entity_id: Uuid) -> Option { + self.body(entity_id).map(|b| from_rapier(b.linvel())) + } + + /// Read the entity's current angular velocity. Returns `None` if no body. + pub fn angvel(&self, entity_id: Uuid) -> Option { + self.body(entity_id).map(|b| from_rapier(b.angvel())) + } + + /// Wake a sleeping body so it rejoins simulation. Returns `false` if no body. + pub fn wake(&mut self, entity_id: Uuid) -> bool { + let Some(body) = self.body_mut(entity_id) else { + return false; + }; + body.wake_up(true); + true + } + + /// Force a body to sleep. Returns `false` if no body. + pub fn sleep(&mut self, entity_id: Uuid) -> bool { + let Some(body) = self.body_mut(entity_id) else { + return false; + }; + body.sleep(); + true + } + + /// Cast a ray and return the closest entity-collider hit, if any. + /// `direction` should be a non-zero vector — its length scales + /// `time_of_impact` (use a unit vector if you want `time_of_impact` to + /// equal world-space distance). Misses return `None`. + pub fn raycast(&self, origin: Vec3, direction: Vec3, max_dist: f32) -> Option { + let ray = Ray::new(to_rapier(origin), to_rapier(direction)); + let qp = self.state.broad_phase.as_query_pipeline( + self.state.narrow_phase.query_dispatcher(), + &self.state.bodies, + &self.state.colliders, + QueryFilter::default(), + ); + let (handle, hit) = qp.cast_ray_and_get_normal(&ray, max_dist, true)?; + let entity_id = *self.state.collider_to_entity.get(&handle)?; + let hit_point = ray.origin + ray.dir * hit.time_of_impact; + Some(RaycastHit::new( + entity_id, + hit.time_of_impact, + from_rapier(hit_point), + from_rapier(hit.normal), + )) + } + + /// Return the entity ids whose colliders overlap a query shape positioned + /// at `position`. The shape is constructed transiently for the query and + /// is not added to the world. + pub fn intersections_with_shape( + &self, + shape: &RapierColliderShape, + position: Vec3, + ) -> Vec { + let collider = build_collider( + *shape, + RapierMaterial::default(), + RapierCollisionGroups::default(), + true, // sensor=true: query only, no contact resolution + ); + let shape_pos = Pose::from_translation(to_rapier(position)); + let qp = self.state.broad_phase.as_query_pipeline( + self.state.narrow_phase.query_dispatcher(), + &self.state.bodies, + &self.state.colliders, + QueryFilter::default(), + ); + qp.intersect_shape(shape_pos, collider.shape()) + .filter_map(|(handle, _co)| self.state.collider_to_entity.get(&handle).copied()) + .collect() + } + + /// Create a joint between two entities in this cluster. Returns the + /// resulting [`JointId`], or `None` if either entity has no body. + /// Joints are auto-removed when either entity despawns. + pub fn create_joint(&mut self, a: Uuid, b: Uuid, joint: JointSpec) -> Option { + let h_a = *self.state.handles.get(&a)?; + let h_b = *self.state.handles.get(&b)?; + let joint_data: GenericJoint = match joint { + JointSpec::Fixed { + local_anchor_a, + local_anchor_b, + } => FixedJointBuilder::new() + .local_anchor1(to_rapier(local_anchor_a)) + .local_anchor2(to_rapier(local_anchor_b)) + .build() + .into(), + JointSpec::Revolute { + local_anchor_a, + local_anchor_b, + axis, + } => RevoluteJointBuilder::new(to_rapier(axis).normalize()) + .local_anchor1(to_rapier(local_anchor_a)) + .local_anchor2(to_rapier(local_anchor_b)) + .build() + .into(), + JointSpec::Spherical { + local_anchor_a, + local_anchor_b, + } => SphericalJointBuilder::new() + .local_anchor1(to_rapier(local_anchor_a)) + .local_anchor2(to_rapier(local_anchor_b)) + .build() + .into(), + JointSpec::Prismatic { + local_anchor_a, + local_anchor_b, + axis, + } => PrismaticJointBuilder::new(to_rapier(axis).normalize()) + .local_anchor1(to_rapier(local_anchor_a)) + .local_anchor2(to_rapier(local_anchor_b)) + .build() + .into(), + }; + let handle = self.state.impulse_joints.insert(h_a, h_b, joint_data, true); + Some(JointId(handle)) + } + + /// Remove a joint previously created by [`Self::create_joint`]. Returns + /// `false` if the joint id is unknown (already removed via despawn or + /// removed by an earlier call). + pub fn remove_joint(&mut self, joint: JointId) -> bool { + self.state.impulse_joints.remove(joint.0, true).is_some() + } +} + /// User-logic backend wrapped by [`RapierClusterSim`]. enum Backend { /// No user-side logic — Rapier just integrates whatever `entity.velocity` @@ -811,32 +1235,61 @@ impl RapierClusterSim { impl ClusterSimulation for RapierClusterSim { fn on_tick(&self, ctx: &mut ClusterTickContext<'_>) { - // 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) - }; - match &self.backend { - Backend::None => {} - Backend::Cluster(sim) => sim.on_tick(ctx), + Backend::None => { + // No user code; lock once and run the physics phase. + let mut state = self.state.lock().expect("rapier state lock"); + // Discard any prior contacts — there is no listener. + state.pending_contact_events.clear(); + state.pending_imperative_linvel.clear(); + self.run_physics_phase(&mut state, ctx); + } + Backend::Cluster(sim) => { + // Plain ClusterSimulation: lock released during user code + // (legacy behavior — plain ClusterSimulation has no PhysicsHandle). + { + let mut state = self.state.lock().expect("rapier state lock"); + // Drop prior contacts — plain ClusterSimulation can't read them. + state.pending_contact_events.clear(); + state.pending_imperative_linvel.clear(); + } + sim.on_tick(ctx); + let mut state = self.state.lock().expect("rapier state lock"); + self.run_physics_phase(&mut state, 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); + // Lock held through user `on_tick` so PhysicsHandle can mutate + // Rapier state synchronously (impulses, raycasts, joints, etc.). + let mut state = self.state.lock().expect("rapier state lock"); + let prev_contacts = std::mem::take(&mut state.pending_contact_events); + state.pending_imperative_linvel.clear(); + { + let physics = PhysicsHandle::new(&mut state); + 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, + physics, + }; + sim.on_tick(&mut rapier_ctx); + // rapier_ctx (and physics) drop here; state is freely usable again. + } + self.run_physics_phase(&mut state, ctx); } } + } +} - let mut state = self.state.lock().expect("rapier state lock"); - +impl RapierClusterSim { + /// Despawn / spawn / per-tick velocity sync / step / sync_outputs. Runs + /// after the user's `on_tick`. Honors `state.pending_imperative_linvel` + /// — entities whose linvel was set imperatively this tick skip the + /// declarative `entity.velocity` → `body.linvel` sync. + fn run_physics_phase(&self, state: &mut RapierState, ctx: &mut ClusterTickContext<'_>) { // 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). @@ -851,7 +1304,12 @@ impl ClusterSimulation for RapierClusterSim { continue; } if state.handles.contains_key(id) { - state.set_linvel(*id, entry.velocity); + // Skip the declarative linvel sync for entities whose linvel + // was set imperatively this tick (via PhysicsHandle::set_linvel + // or apply_impulse) — the imperative override wins. + if !state.pending_imperative_linvel.contains(id) { + state.set_linvel(*id, entry.velocity); + } } else { let params = SpawnParams { shape: self.shape_for(entry), @@ -2899,4 +3357,494 @@ mod tests { ); } } + + // ─── in-tick imperative ops (#121): impulses / forces / set_* / queries / joints ── + + /// Generic test fixture: each tick, run the user-provided closure against + /// the [`PhysicsHandle`] and the current tick number. The closure may use + /// interior mutability (e.g. `Mutex>`) to record results. + struct ScriptedSim + where + F: Fn(&mut PhysicsHandle, u64) + Send + Sync, + { + action: F, + } + + impl RapierClusterSimulation for ScriptedSim + where + F: Fn(&mut PhysicsHandle, u64) + Send + Sync, + { + fn on_tick(&self, ctx: &mut RapierClusterTickContext<'_>) { + (self.action)(&mut ctx.physics, ctx.tick); + } + } + + fn run_with_action( + config: RapierConfig, + action: F, + seed_entities: Vec<(Uuid, EntityStateEntry)>, + ticks: u64, + ) -> ( + Arc>, + RapierClusterSim, + HashMap, + ) + where + F: Fn(&mut PhysicsHandle, u64) + Send + Sync + 'static, + { + let sim_arc = Arc::new(ScriptedSim { action }); + let sim = RapierClusterSim::with_rapier_sim( + sim_arc.clone() as Arc, + config, + ); + let mut entities: HashMap = seed_entities.into_iter().collect(); + step_n(&sim, &mut entities, ticks, CLUSTER_DT); + (sim_arc, sim, entities) + } + + /// **#121-T1**: `apply_impulse` produces a linvel change of `impulse / mass`. + /// With unit-density ball of radius 0.5 (mass ≈ 0.524), an impulse of + /// (10, 0, 0) should yield a positive linvel along x. Applied at tick 2 + /// because the entity is spawned during tick 1's spawn loop, after on_tick. + #[test] + fn apply_impulse_changes_linvel_proportional_to_impulse_over_mass() { + let id = Uuid::from_u128(1); + let action = move |physics: &mut PhysicsHandle, tick: u64| { + if tick == 2 { + physics.apply_impulse(id, Vec3::new(10.0, 0.0, 0.0)); + } + }; + let (_sim_arc, _sim, entities) = run_with_action( + RapierConfig::default(), + action, + vec![( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + )], + 2, + ); + // After tick 2, body.linvel reflects the impulse (touched_linvel skip + // prevents the per-tick set_linvel sync from clobbering it). For unit- + // density ball (mass ≈ 0.524), 10 N·s impulse yields vx ≈ 19. + let v = entities.get(&id).unwrap().velocity; + assert!( + v.x > 5.0, + "expected positive x velocity from impulse; got {:?}", + v + ); + } + + /// **#121-T2**: `apply_force` sustained over multiple ticks produces + /// approximately linear velocity growth (constant acceleration). + #[test] + fn apply_force_over_multiple_ticks_produces_acceleration() { + let id = Uuid::from_u128(1); + let action = move |physics: &mut PhysicsHandle, _tick: u64| { + physics.apply_force(id, Vec3::new(5.0, 0.0, 0.0)); + }; + let (_sim_arc, _sim, entities) = run_with_action( + RapierConfig::default(), + action, + vec![( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + )], + 20, + ); + // After 20 cluster ticks (1 s), constant force should have produced + // significant positive velocity along x. + let v = entities.get(&id).unwrap().velocity; + assert!( + v.x > 1.0, + "expected positive x velocity from sustained force; got {:?}", + v + ); + } + + /// **#121-T3**: `set_translation` teleports the body; the new position + /// propagates to `entity.position` via `sync_outputs`. + #[test] + fn set_translation_teleports_and_propagates_to_entity_position() { + let id = Uuid::from_u128(1); + let action = move |physics: &mut PhysicsHandle, tick: u64| { + if tick == 2 { + physics.set_translation(id, Vec3::new(100.0, 50.0, -25.0)); + } + }; + let (_sim_arc, _sim, entities) = run_with_action( + RapierConfig::default(), + action, + vec![( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + )], + 3, + ); + let p = entities.get(&id).unwrap().position; + assert!(close(p.x, 100.0, 0.5), "x = {}", p.x); + assert!(close(p.y, 50.0, 0.5), "y = {}", p.y); + assert!(close(p.z, -25.0, 0.5), "z = {}", p.z); + } + + /// **#121-T4**: imperative `set_linvel` is NOT clobbered by the per-tick + /// `entity.velocity` → `body.linvel` sync. The user wrote `entity.velocity = (1,0,0)` + /// before this tick (seeded), but the imperative call overrides to (5,0,0). + /// After the step, body's velocity must be the imperative value. + #[test] + fn set_linvel_imperative_overrides_per_tick_sync() { + let id = Uuid::from_u128(1); + let action = move |physics: &mut PhysicsHandle, tick: u64| { + if tick == 2 { + // entity.velocity is (1,0,0); imperatively force it to (5,0,0). + physics.set_linvel(id, Vec3::new(5.0, 0.0, 0.0)); + } + }; + let (_sim_arc, sim, entities) = run_with_action( + RapierConfig::default(), + action, + vec![( + id, + mk_entry(id, Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)), + )], + 2, + ); + // The body should reflect the imperative linvel post-tick. Sync_outputs + // writes body.linvel to entity.velocity at end of tick. + let v = entities.get(&id).unwrap().velocity; + assert!( + close(v.x, 5.0, 0.1), + "imperative set_linvel was clobbered; expected vx ≈ 5.0, got {}", + v.x + ); + let _ = sim; + } + + /// **#121-T5**: `raycast` finds the entity collider in the ray's path and + /// returns its `entity_id` plus the time-of-impact / hit point. + #[test] + fn raycast_hits_collider_in_line() { + let target = Uuid::from_u128(7); + let result: Arc>> = Arc::new(Mutex::new(None)); + let result_clone = result.clone(); + let action = move |physics: &mut PhysicsHandle, tick: u64| { + if tick == 2 { + // Ray from origin shooting +X. Target ball at (10,0,0) radius 0.5. + let hit = physics.raycast(Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), 20.0); + *result_clone.lock().unwrap() = hit; + } + }; + let (_sim_arc, _sim, _entities) = run_with_action( + RapierConfig::default(), + action, + vec![( + target, + mk_entry(target, Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + )], + 2, + ); + let hit = result + .lock() + .unwrap() + .expect("ray should hit the target collider"); + assert_eq!(hit.entity_id, target); + // Target at x=10, ball radius 0.5 → first hit at x≈9.5. + assert!( + (hit.time_of_impact - 9.5).abs() < 0.5, + "expected toi≈9.5, got {}", + hit.time_of_impact + ); + } + + /// **#121-T6**: `raycast` returns `None` when no collider is in the ray's path. + #[test] + fn raycast_misses_when_no_collider_in_line() { + let target = Uuid::from_u128(7); + let result: Arc>> = Arc::new(Mutex::new(None)); + let recorded: Arc> = Arc::new(Mutex::new(false)); + let result_clone = result.clone(); + let recorded_clone = recorded.clone(); + let action = move |physics: &mut PhysicsHandle, tick: u64| { + if tick == 2 { + // Target is at +X (10,0,0); ray shoots straight up — should miss. + *result_clone.lock().unwrap() = + physics.raycast(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0), 50.0); + *recorded_clone.lock().unwrap() = true; + } + }; + let (_sim_arc, _sim, _entities) = run_with_action( + RapierConfig::default(), + action, + vec![( + target, + mk_entry(target, Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + )], + 2, + ); + assert!(*recorded.lock().unwrap(), "raycast call did not happen"); + assert!( + result.lock().unwrap().is_none(), + "raycast should miss; got {:?}", + result.lock().unwrap() + ); + } + + /// **#121-T7**: `intersections_with_shape` returns the entity ids whose + /// colliders overlap a query shape positioned in the world. + #[test] + fn intersections_with_shape_returns_overlapping_entities() { + let near = Uuid::from_u128(1); + let far = Uuid::from_u128(2); + let result: Arc>> = Arc::new(Mutex::new(Vec::new())); + let result_clone = result.clone(); + let action = move |physics: &mut PhysicsHandle, tick: u64| { + if tick == 2 { + // Sphere of radius 5 at origin should hit `near` (at 2,0,0) + // and miss `far` (at 100,0,0). + let hits = physics.intersections_with_shape( + &RapierColliderShape::Ball(5.0), + Vec3::new(0.0, 0.0, 0.0), + ); + *result_clone.lock().unwrap() = hits; + } + }; + let (_sim_arc, _sim, _entities) = run_with_action( + RapierConfig::default(), + action, + vec![ + ( + near, + mk_entry(near, Vec3::new(2.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ), + ( + far, + mk_entry(far, Vec3::new(100.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ), + ], + 2, + ); + let hits = result.lock().unwrap(); + assert!(hits.contains(&near), "near should be hit; got {:?}", hits); + assert!( + !hits.contains(&far), + "far should NOT be hit; got {:?}", + hits + ); + } + + /// **#121-T8**: a Fixed joint between two entities holds them at fixed + /// relative positions. After many ticks of the second entity having a + /// non-zero velocity, the relative offset should be roughly constant. + #[test] + fn fixed_joint_holds_entities_at_fixed_offset() { + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + let joint_created: Arc> = Arc::new(Mutex::new(false)); + let joint_created_clone = joint_created.clone(); + let action = move |physics: &mut PhysicsHandle, tick: u64| { + if tick == 2 && !*joint_created_clone.lock().unwrap() { + // Anchor at the midpoint between the two bodies (bodies at + // distance 2 along x, anchor at +1 in A's frame, -1 in B's frame). + let result = physics.create_joint( + a, + b, + JointSpec::Fixed { + local_anchor_a: Vec3::new(1.0, 0.0, 0.0), + local_anchor_b: Vec3::new(-1.0, 0.0, 0.0), + }, + ); + assert!(result.is_some(), "create_joint should succeed"); + *joint_created_clone.lock().unwrap() = true; + } + }; + let (_sim_arc, _sim, entities) = run_with_action( + RapierConfig::default(), + action, + vec![ + ( + a, + mk_entry(a, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ), + ( + b, + mk_entry(b, Vec3::new(2.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)), + ), + ], + 30, + ); + // Without a joint, b would drift to ~x=3.5 by tick 30 (~1.5s × 1 unit/s, minus + // collision contributions). With the joint, a and b should still be ~2 apart. + let pa = entities.get(&a).unwrap().position; + let pb = entities.get(&b).unwrap().position; + let dx = (pb.x - pa.x).abs(); + assert!( + (dx - 2.0).abs() < 0.5, + "fixed joint failed: |b.x - a.x| = {} (expected ~2.0)", + dx + ); + } + + /// **#121-T9**: when an entity in a joint is despawned, the joint is + /// cleaned up automatically. Subsequent `remove_joint` returns `false`. + #[test] + fn despawn_cleans_up_attached_joints() { + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + let joint_id_holder: Arc>> = Arc::new(Mutex::new(None)); + let joint_id_clone = joint_id_holder.clone(); + let despawn_signal: Arc> = Arc::new(Mutex::new(false)); + let despawn_signal_clone = despawn_signal.clone(); + let remove_after_despawn_result: Arc>> = Arc::new(Mutex::new(None)); + let remove_clone = remove_after_despawn_result.clone(); + + struct DespawnSim { + a: Uuid, + joint_id_holder: Arc>>, + despawn_signal: Arc>, + remove_after_despawn_result: Arc>>, + other: Uuid, + } + impl RapierClusterSimulation for DespawnSim { + fn on_tick(&self, ctx: &mut RapierClusterTickContext<'_>) { + if ctx.tick == 2 { + let id = ctx.physics.create_joint( + self.a, + self.other, + JointSpec::Fixed { + local_anchor_a: Vec3::new(0.5, 0.0, 0.0), + local_anchor_b: Vec3::new(-0.5, 0.0, 0.0), + }, + ); + *self.joint_id_holder.lock().unwrap() = id; + } + if ctx.tick == 3 { + ctx.pending_removals.push(self.a); + *self.despawn_signal.lock().unwrap() = true; + } + if ctx.tick == 5 { + if let Some(j) = *self.joint_id_holder.lock().unwrap() { + let r = ctx.physics.remove_joint(j); + *self.remove_after_despawn_result.lock().unwrap() = Some(r); + } + } + } + } + + let sim_arc = Arc::new(DespawnSim { + a, + other: b, + joint_id_holder: joint_id_clone, + despawn_signal: despawn_signal_clone, + remove_after_despawn_result: remove_clone, + }); + let sim = RapierClusterSim::with_rapier_sim( + sim_arc 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(2.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)), + ); + step_n(&sim, &mut entities, 5, CLUSTER_DT); + + assert!( + *despawn_signal.lock().unwrap(), + "despawn signal should have fired" + ); + let id = joint_id_holder.lock().unwrap(); + assert!(id.is_some(), "joint should have been created"); + let r = remove_after_despawn_result.lock().unwrap(); + assert_eq!( + *r, + Some(false), + "remove_joint after despawn should return false (joint already gone)" + ); + } + + /// **#121-T10**: operations on a missing `entity_id` return `false` / + /// `None` without panicking. + #[test] + fn operations_on_missing_entity_id_no_panic() { + let unknown = Uuid::from_u128(999); + let action = move |physics: &mut PhysicsHandle, tick: u64| { + if tick == 1 { + assert!(!physics.apply_impulse(unknown, Vec3::new(1.0, 0.0, 0.0))); + assert!(!physics.apply_force(unknown, Vec3::new(1.0, 0.0, 0.0))); + assert!(!physics.apply_torque_impulse(unknown, Vec3::new(1.0, 0.0, 0.0))); + assert!(!physics.set_translation(unknown, Vec3::new(0.0, 0.0, 0.0))); + assert!(!physics.set_linvel(unknown, Vec3::new(0.0, 0.0, 0.0))); + assert!(!physics.set_angvel(unknown, Vec3::new(0.0, 0.0, 0.0))); + assert!(physics.linvel(unknown).is_none()); + assert!(physics.angvel(unknown).is_none()); + assert!(!physics.wake(unknown)); + assert!(!physics.sleep(unknown)); + let joint = physics.create_joint( + unknown, + unknown, + JointSpec::Fixed { + local_anchor_a: Vec3::new(0.0, 0.0, 0.0), + local_anchor_b: Vec3::new(0.0, 0.0, 0.0), + }, + ); + assert!(joint.is_none()); + } + }; + let (_sim_arc, _sim, _entities) = + run_with_action(RapierConfig::default(), action, vec![], 1); + } + + /// **#121-T11**: imperative ops on a `Fixed` body silently no-op and + /// return `false`; the body's state is unchanged. + #[test] + fn imperative_ops_on_fixed_body_are_no_ops() { + let id = Uuid::from_u128(1); + let result: Arc>> = Arc::new(Mutex::new(Vec::new())); + let result_clone = result.clone(); + struct FixedSim { + id: Uuid, + result: Arc>>, + } + impl RapierClusterSimulation for FixedSim { + fn on_tick(&self, ctx: &mut RapierClusterTickContext<'_>) { + if ctx.tick == 2 { + let r1 = ctx + .physics + .apply_impulse(self.id, Vec3::new(100.0, 0.0, 0.0)); + let r2 = ctx.physics.apply_force(self.id, Vec3::new(100.0, 0.0, 0.0)); + let r3 = ctx.physics.set_linvel(self.id, Vec3::new(50.0, 0.0, 0.0)); + self.result.lock().unwrap().extend([r1, r2, r3]); + } + } + fn body_kind_for( + &self, + _entry: &EntityStateEntry, + _config: &RapierConfig, + ) -> RapierBodyKind { + RapierBodyKind::Fixed + } + } + let sim_arc = Arc::new(FixedSim { + id, + result: result_clone, + }); + let sim = RapierClusterSim::with_rapier_sim( + sim_arc as Arc, + RapierConfig::default(), + ); + let mut entities = HashMap::new(); + let start = Vec3::new(5.0, 5.0, 5.0); + entities.insert(id, mk_entry(id, start, Vec3::new(0.0, 0.0, 0.0))); + step_n(&sim, &mut entities, 4, CLUSTER_DT); + + // All three imperative ops should have returned false. + let results = result.lock().unwrap(); + assert_eq!(*results, vec![false, false, false]); + // Body should not have moved. + let p = entities.get(&id).unwrap().position; + assert!(close(p.x, start.x, 1e-6) && close(p.y, start.y, 1e-6)); + } }