initial working demo sans networking

Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
This commit is contained in:
2025-12-11 22:10:06 +00:00
parent 4965d13070
commit 63312e09cb
40 changed files with 1552 additions and 3145 deletions

50
crates/app/Cargo.toml Normal file
View File

@@ -0,0 +1,50 @@
[package]
name = "app"
version = "0.1.0"
edition = "2021"
[features]
default = ["desktop"]
desktop = [] # macOS only
ios = []
headless = []
[dependencies]
lib = { path = "../lib" }
bevy = { version = "0.17", default-features = false, features = [
"bevy_winit",
"bevy_render",
"bevy_core_pipeline",
"bevy_pbr",
"bevy_ui",
"bevy_text",
"png",
] }
bevy_egui = "0.38"
uuid = { version = "1.0", features = ["v4", "serde"] }
anyhow = "1.0"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1.0", features = ["derive"] }
iroh = { version = "0.95", features = ["discovery-local-network"] }
iroh-gossip = "0.95"
futures-lite = "2.0"
bincode = "1.3"
bytes = "1.0"
crossbeam-channel = "0.5.15"
[target.'cfg(target_os = "ios")'.dependencies]
objc = "0.2"
[dev-dependencies]
iroh = { version = "0.95", features = ["discovery-local-network"] }
iroh-gossip = "0.95"
tempfile = "3"
futures-lite = "2.0"
bincode = "1.3"
bytes = "1.0"
[lib]
name = "app"
crate-type = ["staticlib", "cdylib", "lib"]

27
crates/app/src/camera.rs Normal file
View File

@@ -0,0 +1,27 @@
//! Camera configuration
//!
//! This module handles the 3D camera setup for the cube demo.
use bevy::prelude::*;
pub struct CameraPlugin;
impl Plugin for CameraPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, setup_camera);
}
}
/// Set up the 3D camera
///
/// Camera is positioned at (4, 3, 6) looking at the cube's initial position (0, 0.5, 0).
/// This provides a good viewing angle to see the cube, ground plane, and any movements.
fn setup_camera(mut commands: Commands) {
info!("Setting up camera");
commands.spawn((
Camera3d::default(),
Transform::from_xyz(4.0, 3.0, 6.0)
.looking_at(Vec3::new(0.0, 0.5, 0.0), Vec3::Y),
));
}

65
crates/app/src/cube.rs Normal file
View File

@@ -0,0 +1,65 @@
//! Cube entity management
use bevy::prelude::*;
use lib::{
networking::{NetworkedEntity, NetworkedTransform, Synced},
persistence::Persisted,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Marker component for the replicated cube
#[derive(Component, Reflect, Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[reflect(Component)]
pub struct CubeMarker;
pub struct CubePlugin;
impl Plugin for CubePlugin {
fn build(&self, app: &mut App) {
app.register_type::<CubeMarker>()
.add_systems(Startup, spawn_cube);
}
}
/// Spawn the synced cube on startup
fn spawn_cube(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
node_clock: Option<Res<lib::networking::NodeVectorClock>>,
) {
// Wait until NodeVectorClock is available (after networking plugin initializes)
let Some(clock) = node_clock else {
warn!("NodeVectorClock not ready, deferring cube spawn");
return;
};
let entity_id = Uuid::new_v4();
let node_id = clock.node_id;
info!("Spawning cube with network ID: {}", entity_id);
commands.spawn((
CubeMarker,
// Bevy 3D components
Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: Color::srgb(0.8, 0.3, 0.6),
perceptual_roughness: 0.7,
metallic: 0.3,
..default()
})),
Transform::from_xyz(0.0, 0.5, 0.0),
GlobalTransform::default(),
// Networking
NetworkedEntity::with_id(entity_id, node_id),
NetworkedTransform,
// Persistence
Persisted::with_id(entity_id),
// Sync marker
Synced,
));
info!("Cube spawned successfully");
}

View File

@@ -0,0 +1,89 @@
//! Debug UI overlay using egui
use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass};
use lib::networking::{GossipBridge, NodeVectorClock};
pub struct DebugUiPlugin;
impl Plugin for DebugUiPlugin {
fn build(&self, app: &mut App) {
app.add_systems(EguiPrimaryContextPass, render_debug_ui);
}
}
/// Render the debug UI panel
fn render_debug_ui(
mut contexts: EguiContexts,
node_clock: Option<Res<NodeVectorClock>>,
gossip_bridge: Option<Res<GossipBridge>>,
cube_query: Query<(&Transform, &lib::networking::NetworkedEntity), With<crate::cube::CubeMarker>>,
) {
let Ok(ctx) = contexts.ctx_mut() else {
return;
};
egui::Window::new("Debug Info")
.default_pos([10.0, 10.0])
.default_width(300.0)
.resizable(true)
.show(ctx, |ui| {
ui.heading("Network Status");
ui.separator();
// Node information
if let Some(clock) = &node_clock {
ui.label(format!("Node ID: {}", &clock.node_id.to_string()[..8]));
// Show the current node's clock value (timestamp)
let current_timestamp = clock.clock.clocks.get(&clock.node_id).copied().unwrap_or(0);
ui.label(format!("Clock: {}", current_timestamp));
ui.label(format!("Known nodes: {}", clock.clock.clocks.len()));
} else {
ui.label("Node: Not initialized");
}
ui.add_space(5.0);
// Gossip bridge status
if let Some(bridge) = &gossip_bridge {
ui.label(format!("Bridge Node: {}", &bridge.node_id().to_string()[..8]));
ui.label("Status: Connected");
} else {
ui.label("Gossip: Not ready");
}
ui.add_space(10.0);
ui.heading("Cube State");
ui.separator();
// Cube information
match cube_query.iter().next() {
Some((transform, networked)) => {
let pos = transform.translation;
ui.label(format!("Position: ({:.2}, {:.2}, {:.2})", pos.x, pos.y, pos.z));
let (axis, angle) = transform.rotation.to_axis_angle();
let angle_deg: f32 = angle.to_degrees();
ui.label(format!("Rotation: {:.2}° around ({:.2}, {:.2}, {:.2})",
angle_deg, axis.x, axis.y, axis.z));
ui.label(format!("Scale: ({:.2}, {:.2}, {:.2})",
transform.scale.x, transform.scale.y, transform.scale.z));
ui.add_space(5.0);
ui.label(format!("Network ID: {}", &networked.network_id.to_string()[..8]));
ui.label(format!("Owner: {}", &networked.owner_node_id.to_string()[..8]));
}
None => {
ui.label("Cube: Not spawned yet");
}
}
ui.add_space(10.0);
ui.heading("Controls");
ui.separator();
ui.label("Left drag: Move cube (XY)");
ui.label("Right drag: Rotate cube");
ui.label("Scroll: Move cube (Z)");
});
}

View File

@@ -0,0 +1,79 @@
//! Mouse input handling for macOS
use bevy::prelude::*;
use bevy::input::mouse::{MouseMotion, MouseWheel};
pub struct MouseInputPlugin;
impl Plugin for MouseInputPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, handle_mouse_input);
}
}
/// Mouse interaction state
#[derive(Resource, Default)]
struct MouseState {
/// Whether the left mouse button is currently pressed
left_pressed: bool,
/// Whether the right mouse button is currently pressed
right_pressed: bool,
}
/// Handle mouse input to move and rotate the cube
fn handle_mouse_input(
mouse_buttons: Res<ButtonInput<MouseButton>>,
mut mouse_motion: EventReader<MouseMotion>,
mut mouse_wheel: EventReader<MouseWheel>,
mut mouse_state: Local<Option<MouseState>>,
mut cube_query: Query<&mut Transform, With<crate::cube::CubeMarker>>,
) {
// Initialize mouse state if needed
if mouse_state.is_none() {
*mouse_state = Some(MouseState::default());
}
let state = mouse_state.as_mut().unwrap();
// Update button states
state.left_pressed = mouse_buttons.pressed(MouseButton::Left);
state.right_pressed = mouse_buttons.pressed(MouseButton::Right);
// Get total mouse delta this frame
let mut total_delta = Vec2::ZERO;
for motion in mouse_motion.read() {
total_delta += motion.delta;
}
// Process mouse motion
if total_delta != Vec2::ZERO {
for mut transform in cube_query.iter_mut() {
if state.left_pressed {
// Left drag: Move cube in XY plane
// Scale factor for sensitivity
let sensitivity = 0.01;
transform.translation.x += total_delta.x * sensitivity;
transform.translation.y -= total_delta.y * sensitivity; // Invert Y
} else if state.right_pressed {
// Right drag: Rotate cube
let sensitivity = 0.01;
let rotation_x = Quat::from_rotation_y(total_delta.x * sensitivity);
let rotation_y = Quat::from_rotation_x(-total_delta.y * sensitivity);
transform.rotation = rotation_x * transform.rotation * rotation_y;
}
}
}
// Process mouse wheel for Z-axis movement
let mut total_scroll = 0.0;
for wheel in mouse_wheel.read() {
total_scroll += wheel.y;
}
if total_scroll != 0.0 {
for mut transform in cube_query.iter_mut() {
// Scroll: Move in Z axis
let sensitivity = 0.1;
transform.translation.z += total_scroll * sensitivity;
}
}
}

12
crates/app/src/lib.rs Normal file
View File

@@ -0,0 +1,12 @@
//! Replicated cube demo app
//!
//! This app demonstrates real-time CRDT synchronization between iPad and Mac
//! with Apple Pencil input controlling a 3D cube.
pub mod camera;
pub mod cube;
pub mod debug_ui;
pub mod rendering;
pub mod setup;
pub use cube::CubeMarker;

101
crates/app/src/main.rs Normal file
View File

@@ -0,0 +1,101 @@
//! Replicated cube demo - macOS and iPad
//!
//! This demonstrates real-time CRDT synchronization with Apple Pencil input.
use bevy::prelude::*;
use bevy_egui::EguiPlugin;
use lib::{
networking::{NetworkingConfig, NetworkingPlugin},
persistence::{PersistenceConfig, PersistencePlugin},
};
use std::path::PathBuf;
use uuid::Uuid;
mod camera;
mod cube;
mod debug_ui;
mod rendering;
mod setup;
#[cfg(not(target_os = "ios"))]
mod input {
pub mod mouse;
pub use mouse::MouseInputPlugin;
}
#[cfg(target_os = "ios")]
mod input {
pub mod pencil;
pub use pencil::PencilInputPlugin;
}
use camera::*;
use cube::*;
use debug_ui::*;
use input::*;
use rendering::*;
use setup::*;
fn main() {
// Initialize logging
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("wgpu=error".parse().unwrap())
.add_directive("naga=warn".parse().unwrap()),
)
.init();
// Create node ID (in production, load from config or generate once)
let node_id = Uuid::new_v4();
info!("Starting app with node ID: {}", node_id);
// Database path
let db_path = PathBuf::from("cube_demo.db");
// Create Bevy app
App::new()
.add_plugins(DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window {
title: format!("Replicated Cube Demo - Node {}", &node_id.to_string()[..8]),
resolution: (1280, 720).into(),
..default()
}),
..default()
})
.disable::<bevy::log::LogPlugin>() // Disable Bevy's logger, using tracing-subscriber instead
)
.add_plugins(EguiPlugin::default())
// Networking (bridge will be set up in startup)
.add_plugins(NetworkingPlugin::new(NetworkingConfig {
node_id,
sync_interval_secs: 1.0,
prune_interval_secs: 60.0,
tombstone_gc_interval_secs: 300.0,
}))
// Persistence
.add_plugins(PersistencePlugin::with_config(
db_path,
PersistenceConfig {
flush_interval_secs: 2,
checkpoint_interval_secs: 30,
battery_adaptive: true,
..Default::default()
},
))
// Camera
.add_plugins(CameraPlugin)
// Rendering
.add_plugins(RenderingPlugin)
// Input
.add_plugins(MouseInputPlugin)
// Cube management
.add_plugins(CubePlugin)
// Debug UI
.add_plugins(DebugUiPlugin)
// Gossip networking setup
.add_systems(Startup, setup_gossip_networking)
.add_systems(Update, poll_gossip_bridge)
.run();
}

View File

@@ -0,0 +1,53 @@
//! Lighting and ground plane setup
use bevy::prelude::*;
pub struct RenderingPlugin;
impl Plugin for RenderingPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, setup_lighting_and_ground);
}
}
/// Set up lighting and ground plane
///
/// Creates a directional light (sun), ambient light, and a green ground plane.
/// Camera setup is handled separately in the camera module.
fn setup_lighting_and_ground(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
info!("Setting up lighting and ground plane");
// Directional light (sun)
commands.spawn((
DirectionalLight {
illuminance: 10000.0,
shadows_enabled: true,
..default()
},
Transform::from_xyz(4.0, 8.0, 4.0).looking_at(Vec3::ZERO, Vec3::Y),
));
// Ambient light
commands.insert_resource(AmbientLight {
color: Color::WHITE,
brightness: 150.0,
affects_lightmapped_meshes: false,
});
// Ground plane
commands.spawn((
Mesh3d(meshes.add(Plane3d::default().mesh().size(20.0, 20.0))),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: Color::srgb(0.3, 0.5, 0.3),
perceptual_roughness: 0.9,
..default()
})),
Transform::from_xyz(0.0, 0.0, 0.0),
));
info!("Lighting and ground setup complete");
}

279
crates/app/src/setup.rs Normal file
View File

@@ -0,0 +1,279 @@
//! Gossip networking setup with dedicated tokio runtime
//!
//! This module manages iroh-gossip networking with a tokio runtime running as a sidecar to Bevy.
//! The tokio runtime runs in a dedicated background thread, separate from Bevy's ECS loop.
//!
//! # Architecture
//!
//! ```text
//! ┌─────────────────────────────────────┐
//! │ Bevy Main Thread │
//! │ ┌────────────────────────────────┐ │
//! │ │ setup_gossip_networking() │ │ Startup System
//! │ │ - Creates channel │ │
//! │ │ - Spawns background thread │ │
//! │ └────────────────────────────────┘ │
//! │ ┌────────────────────────────────┐ │
//! │ │ poll_gossip_bridge() │ │ Update System
//! │ │ - Receives GossipBridge │ │ (runs every frame)
//! │ │ - Inserts as resource │ │
//! │ └────────────────────────────────┘ │
//! └─────────────────────────────────────┘
//! ↕ (crossbeam channel)
//! ┌─────────────────────────────────────┐
//! │ Background Thread (macOS only) │
//! │ ┌────────────────────────────────┐ │
//! │ │ Tokio Runtime │ │
//! │ │ ┌────────────────────────────┐ │ │
//! │ │ │ init_gossip() │ │ │
//! │ │ │ - Creates iroh endpoint │ │ │
//! │ │ │ - Sets up mDNS discovery │ │ │
//! │ │ │ - Subscribes to topic │ │ │
//! │ │ │ - Creates GossipBridge │ │ │
//! │ │ └────────────────────────────┘ │ │
//! │ │ ┌────────────────────────────┐ │ │
//! │ │ │ spawn_bridge_tasks() │ │ │
//! │ │ │ - Task 1: Forward outgoing │ │ │
//! │ │ │ - Task 2: Forward incoming │ │ │
//! │ │ └────────────────────────────┘ │ │
//! │ └────────────────────────────────┘ │
//! └─────────────────────────────────────┘
//! ```
//!
//! # Communication Pattern
//!
//! 1. **Bevy → Tokio**: GossipBridge's internal queue (try_recv_outgoing)
//! 2. **Tokio → Bevy**: GossipBridge's internal queue (push_incoming)
//! 3. **Thread handoff**: crossbeam_channel (one-time GossipBridge transfer)
use anyhow::Result;
use bevy::prelude::*;
use lib::networking::GossipBridge;
use uuid::Uuid;
/// Channel for receiving the GossipBridge from the background thread
///
/// This resource exists temporarily during startup. Once the GossipBridge
/// is received and inserted, this channel resource is removed.
#[cfg(not(target_os = "ios"))]
#[derive(Resource)]
pub struct GossipBridgeChannel(crossbeam_channel::Receiver<GossipBridge>);
/// Set up gossip networking with iroh
///
/// This is a Bevy startup system that initializes the gossip networking stack.
/// On macOS, it spawns a dedicated thread with a tokio runtime. On iOS, it logs
/// a warning (iOS networking not yet implemented).
///
/// # Platform Support
///
/// - **macOS**: Full support with mDNS discovery
/// - **iOS**: Not yet implemented
pub fn setup_gossip_networking(mut commands: Commands) {
info!("Setting up gossip networking...");
// Spawn dedicated thread with Tokio runtime for gossip initialization
#[cfg(not(target_os = "ios"))]
{
let (sender, receiver) = crossbeam_channel::unbounded();
commands.insert_resource(GossipBridgeChannel(receiver));
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async move {
match init_gossip().await {
Ok(bridge) => {
info!("Gossip bridge initialized successfully");
if let Err(e) = sender.send(bridge) {
error!("Failed to send bridge to main thread: {}", e);
}
}
Err(e) => {
error!("Failed to initialize gossip: {}", e);
}
}
});
});
}
#[cfg(target_os = "ios")]
{
warn!("iOS networking not yet implemented - gossip networking disabled");
}
}
/// Poll the channel for the GossipBridge and insert it when ready
///
/// This is a Bevy update system that runs every frame. It checks the channel
/// for the GossipBridge created in the background thread. Once received, it
/// inserts the bridge as a resource and removes the channel.
///
/// # Platform Support
///
/// - **macOS**: Polls the channel and inserts GossipBridge
/// - **iOS**: No-op (networking not implemented)
pub fn poll_gossip_bridge(
mut commands: Commands,
#[cfg(not(target_os = "ios"))]
channel: Option<Res<GossipBridgeChannel>>,
) {
#[cfg(not(target_os = "ios"))]
if let Some(channel) = channel {
if let Ok(bridge) = channel.0.try_recv() {
info!("Inserting GossipBridge resource into Bevy world");
commands.insert_resource(bridge);
commands.remove_resource::<GossipBridgeChannel>();
}
}
}
/// Initialize iroh-gossip networking stack
///
/// This async function runs in the background tokio runtime and:
/// 1. Creates an iroh endpoint with mDNS discovery
/// 2. Spawns the gossip protocol
/// 3. Sets up the router to accept gossip connections
/// 4. Subscribes to a shared topic (ID: [42; 32])
/// 5. Waits for join with a 2-second timeout
/// 6. Creates and configures the GossipBridge
/// 7. Spawns forwarding tasks to bridge messages
///
/// # Returns
///
/// - `Ok(GossipBridge)`: Successfully initialized networking
/// - `Err(anyhow::Error)`: Initialization failed
///
/// # Platform Support
///
/// This function is only compiled on non-iOS platforms.
#[cfg(not(target_os = "ios"))]
async fn init_gossip() -> Result<GossipBridge> {
use iroh::discovery::mdns::MdnsDiscovery;
use iroh::protocol::Router;
use iroh::Endpoint;
use iroh_gossip::net::Gossip;
use iroh_gossip::proto::TopicId;
info!("Creating endpoint with mDNS discovery...");
let endpoint = Endpoint::builder()
.discovery(MdnsDiscovery::builder())
.bind()
.await?;
let endpoint_id = endpoint.addr().id;
info!("Endpoint created: {}", endpoint_id);
// Convert endpoint ID to UUID
let id_bytes = endpoint_id.as_bytes();
let mut uuid_bytes = [0u8; 16];
uuid_bytes.copy_from_slice(&id_bytes[..16]);
let node_id = Uuid::from_bytes(uuid_bytes);
info!("Spawning gossip protocol...");
let gossip = Gossip::builder().spawn(endpoint.clone());
info!("Setting up router...");
let router = Router::builder(endpoint.clone())
.accept(iroh_gossip::ALPN, gossip.clone())
.spawn();
// Subscribe to shared topic
let topic_id = TopicId::from_bytes([42; 32]);
info!("Subscribing to topic...");
let subscribe_handle = gossip.subscribe(topic_id, vec![]).await?;
let (sender, mut receiver) = subscribe_handle.split();
// Wait for join (with timeout since we might be the first node)
info!("Waiting for gossip join...");
match tokio::time::timeout(std::time::Duration::from_secs(2), receiver.joined()).await {
Ok(Ok(())) => info!("Joined gossip swarm"),
Ok(Err(e)) => warn!("Join error: {} (proceeding anyway)", e),
Err(_) => info!("Join timeout (first node in swarm)"),
}
// Create bridge
let bridge = GossipBridge::new(node_id);
info!("GossipBridge created with node ID: {}", node_id);
// Spawn forwarding tasks - pass endpoint, router, gossip to keep them alive
spawn_bridge_tasks(sender, receiver, bridge.clone(), endpoint, router, gossip);
Ok(bridge)
}
/// Spawn tokio tasks to forward messages between iroh-gossip and GossipBridge
///
/// This function spawns two concurrent tokio tasks that run for the lifetime of the application:
///
/// 1. **Outgoing Task**: Polls GossipBridge for outgoing messages and broadcasts them via gossip
/// 2. **Incoming Task**: Receives messages from gossip and pushes them into GossipBridge
///
/// # Lifetime Management
///
/// The iroh resources (endpoint, router, gossip) are moved into the first task to keep them
/// alive for the application lifetime. Without this, they would be dropped immediately and
/// the gossip connection would close.
///
/// # Platform Support
///
/// This function is only compiled on non-iOS platforms.
#[cfg(not(target_os = "ios"))]
fn spawn_bridge_tasks(
sender: iroh_gossip::api::GossipSender,
mut receiver: iroh_gossip::api::GossipReceiver,
bridge: GossipBridge,
_endpoint: iroh::Endpoint,
_router: iroh::protocol::Router,
_gossip: iroh_gossip::net::Gossip,
) {
use bytes::Bytes;
use futures_lite::StreamExt;
use lib::networking::VersionedMessage;
use std::time::Duration;
let node_id = bridge.node_id();
// Task 1: Forward outgoing messages from GossipBridge → iroh-gossip
// Keep endpoint, router, gossip alive by moving them into this task
let bridge_out = bridge.clone();
tokio::spawn(async move {
let _endpoint = _endpoint; // Keep alive for app lifetime
let _router = _router; // Keep alive for app lifetime
let _gossip = _gossip; // Keep alive for app lifetime
loop {
if let Some(msg) = bridge_out.try_recv_outgoing() {
if let Ok(bytes) = bincode::serialize(&msg) {
if let Err(e) = sender.broadcast(Bytes::from(bytes)).await {
error!("[Node {}] Broadcast failed: {}", node_id, e);
}
}
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
});
// Task 2: Forward incoming messages from iroh-gossip → GossipBridge
let bridge_in = bridge.clone();
tokio::spawn(async move {
loop {
match tokio::time::timeout(Duration::from_millis(100), receiver.next()).await {
Ok(Some(Ok(event))) => {
if let iroh_gossip::api::Event::Received(msg) = event {
if let Ok(versioned_msg) =
bincode::deserialize::<VersionedMessage>(&msg.content)
{
if let Err(e) = bridge_in.push_incoming(versioned_msg) {
error!("[Node {}] Push incoming failed: {}", node_id, e);
}
}
}
}
Ok(Some(Err(e))) => error!("[Node {}] Receiver error: {}", node_id, e),
Ok(None) => break,
Err(_) => {} // Timeout
}
}
});
}

View File

@@ -0,0 +1,471 @@
//! Headless integration tests for cube synchronization
//!
//! These tests validate end-to-end CRDT synchronization of the cube entity
//! using multiple headless Bevy apps with real iroh-gossip networking.
use std::{
path::PathBuf,
time::{Duration, Instant},
};
use anyhow::Result;
use app::CubeMarker;
use bevy::{
app::{App, ScheduleRunnerPlugin},
ecs::world::World,
prelude::*,
MinimalPlugins,
};
use bytes::Bytes;
use futures_lite::StreamExt;
use iroh::{protocol::Router, Endpoint};
use iroh_gossip::{
api::{GossipReceiver, GossipSender},
net::Gossip,
proto::TopicId,
};
use lib::{
networking::{
GossipBridge, NetworkedEntity, NetworkedTransform, NetworkingConfig, NetworkingPlugin,
Synced, VersionedMessage,
},
persistence::{Persisted, PersistenceConfig, PersistencePlugin},
};
use tempfile::TempDir;
use uuid::Uuid;
// ============================================================================
// Test Utilities
// ============================================================================
mod test_utils {
use super::*;
/// Test context that manages temporary directories with RAII cleanup
pub struct TestContext {
_temp_dir: TempDir,
db_path: PathBuf,
}
impl TestContext {
pub fn new() -> Self {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
let db_path = temp_dir.path().join("test.db");
Self {
_temp_dir: temp_dir,
db_path,
}
}
pub fn db_path(&self) -> PathBuf {
self.db_path.clone()
}
}
/// Create a headless Bevy app configured for cube testing
pub fn create_test_app(node_id: Uuid, db_path: PathBuf, bridge: GossipBridge) -> App {
let mut app = App::new();
app.add_plugins(
MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64(
1.0 / 60.0,
))),
)
.insert_resource(bridge)
.add_plugins(NetworkingPlugin::new(NetworkingConfig {
node_id,
sync_interval_secs: 0.5, // Fast for testing
prune_interval_secs: 10.0,
tombstone_gc_interval_secs: 30.0,
}))
.add_plugins(PersistencePlugin::with_config(
db_path,
PersistenceConfig {
flush_interval_secs: 1,
checkpoint_interval_secs: 5,
battery_adaptive: false,
..Default::default()
},
));
// Register cube component types for reflection
app.register_type::<CubeMarker>();
app
}
/// Count entities with CubeMarker component
pub fn count_cubes(world: &mut World) -> usize {
let mut query = world.query::<&CubeMarker>();
query.iter(world).count()
}
/// Count entities with a specific network ID
pub fn count_entities_with_id(world: &mut World, network_id: Uuid) -> usize {
let mut query = world.query::<&NetworkedEntity>();
query
.iter(world)
.filter(|entity| entity.network_id == network_id)
.count()
}
/// Wait for sync condition to be met, polling both apps
pub async fn wait_for_sync<F>(
app1: &mut App,
app2: &mut App,
timeout: Duration,
check_fn: F,
) -> Result<()>
where
F: Fn(&mut World, &mut World) -> bool,
{
let start = Instant::now();
let mut tick_count = 0;
while start.elapsed() < timeout {
// Tick both apps
app1.update();
app2.update();
tick_count += 1;
if tick_count % 50 == 0 {
println!(
"Waiting for sync... tick {} ({:.1}s elapsed)",
tick_count,
start.elapsed().as_secs_f32()
);
}
// Check condition
if check_fn(app1.world_mut(), app2.world_mut()) {
println!(
"Sync completed after {} ticks ({:.3}s)",
tick_count,
start.elapsed().as_secs_f32()
);
return Ok(());
}
// Small delay to avoid spinning
tokio::time::sleep(Duration::from_millis(16)).await;
}
println!("Sync timeout after {} ticks", tick_count);
anyhow::bail!("Sync timeout after {:?}. Condition not met.", timeout)
}
/// Initialize a single iroh-gossip node
async fn init_gossip_node(
topic_id: TopicId,
bootstrap_addrs: Vec<iroh::EndpointAddr>,
) -> Result<(Endpoint, Gossip, Router, GossipBridge)> {
println!(" Creating endpoint with mDNS discovery...");
let endpoint = Endpoint::builder()
.discovery(iroh::discovery::mdns::MdnsDiscovery::builder())
.bind()
.await?;
let endpoint_id = endpoint.addr().id;
println!(" Endpoint created: {}", endpoint_id);
// Convert 32-byte endpoint ID to 16-byte UUID
let id_bytes = endpoint_id.as_bytes();
let mut uuid_bytes = [0u8; 16];
uuid_bytes.copy_from_slice(&id_bytes[..16]);
let node_id = Uuid::from_bytes(uuid_bytes);
println!(" Spawning gossip protocol...");
let gossip = Gossip::builder().spawn(endpoint.clone());
println!(" Setting up router...");
let router = Router::builder(endpoint.clone())
.accept(iroh_gossip::ALPN, gossip.clone())
.spawn();
let bootstrap_count = bootstrap_addrs.len();
let has_bootstrap_peers = !bootstrap_addrs.is_empty();
let bootstrap_ids: Vec<_> = bootstrap_addrs.iter().map(|a| a.id).collect();
if has_bootstrap_peers {
let static_provider = iroh::discovery::static_provider::StaticProvider::default();
for addr in &bootstrap_addrs {
static_provider.add_endpoint_info(addr.clone());
}
endpoint.discovery().add(static_provider);
println!(
" Added {} bootstrap peers to static discovery",
bootstrap_count
);
println!(" Connecting to bootstrap peers...");
for addr in &bootstrap_addrs {
match endpoint.connect(addr.clone(), iroh_gossip::ALPN).await {
Ok(_conn) => println!(" ✓ Connected to bootstrap peer: {}", addr.id),
Err(e) => {
println!(" ✗ Failed to connect to bootstrap peer {}: {}", addr.id, e)
}
}
}
}
println!(
" Subscribing to topic with {} bootstrap peers...",
bootstrap_count
);
let subscribe_handle = gossip.subscribe(topic_id, bootstrap_ids).await?;
println!(" Splitting sender/receiver...");
let (sender, mut receiver) = subscribe_handle.split();
if has_bootstrap_peers {
println!(" Waiting for join to complete (with timeout)...");
match tokio::time::timeout(Duration::from_secs(3), receiver.joined()).await {
Ok(Ok(())) => println!(" Join completed!"),
Ok(Err(e)) => println!(" Join error: {}", e),
Err(_) => {
println!(" Join timeout - proceeding anyway (mDNS may still connect later)")
}
}
} else {
println!(" No bootstrap peers - skipping join wait (first node in swarm)");
}
let bridge = GossipBridge::new(node_id);
println!(" Spawning bridge tasks...");
spawn_gossip_bridge_tasks(sender, receiver, bridge.clone());
println!(" Node initialization complete");
Ok((endpoint, gossip, router, bridge))
}
/// Setup a pair of iroh-gossip nodes connected to the same topic
pub async fn setup_gossip_pair() -> Result<(
Endpoint,
Endpoint,
Router,
Router,
GossipBridge,
GossipBridge,
)> {
let topic_id = TopicId::from_bytes([42; 32]);
println!("Using topic ID: {:?}", topic_id);
println!("Initializing node 1...");
let (ep1, _gossip1, router1, bridge1) = init_gossip_node(topic_id, vec![]).await?;
println!("Node 1 initialized with ID: {}", ep1.addr().id);
let node1_addr = ep1.addr().clone();
println!("Node 1 full address: {:?}", node1_addr);
println!("Initializing node 2 with bootstrap peer: {}", node1_addr.id);
let (ep2, _gossip2, router2, bridge2) =
init_gossip_node(topic_id, vec![node1_addr]).await?;
println!("Node 2 initialized with ID: {}", ep2.addr().id);
println!("Waiting for mDNS/gossip peer discovery...");
tokio::time::sleep(Duration::from_secs(2)).await;
println!("Peer discovery wait complete");
Ok((ep1, ep2, router1, router2, bridge1, bridge2))
}
/// Spawn background tasks to forward messages between iroh-gossip and GossipBridge
fn spawn_gossip_bridge_tasks(
sender: GossipSender,
mut receiver: GossipReceiver,
bridge: GossipBridge,
) {
let node_id = bridge.node_id();
// Task 1: Forward from bridge.outgoing → gossip sender
let bridge_out = bridge.clone();
tokio::spawn(async move {
let mut msg_count = 0;
loop {
if let Some(versioned_msg) = bridge_out.try_recv_outgoing() {
msg_count += 1;
println!(
"[Node {}] Sending message #{} via gossip",
node_id, msg_count
);
match bincode::serialize(&versioned_msg) {
Ok(bytes) => {
if let Err(e) = sender.broadcast(Bytes::from(bytes)).await {
eprintln!("[Node {}] Failed to broadcast message: {}", node_id, e);
} else {
println!(
"[Node {}] Message #{} broadcasted successfully",
node_id, msg_count
);
}
}
Err(e) => eprintln!(
"[Node {}] Failed to serialize message for broadcast: {}",
node_id, e
),
}
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
});
// Task 2: Forward from gossip receiver → bridge.incoming
let bridge_in = bridge.clone();
tokio::spawn(async move {
let mut msg_count = 0;
println!("[Node {}] Gossip receiver task started", node_id);
loop {
match tokio::time::timeout(Duration::from_millis(100), receiver.next()).await {
Ok(Some(Ok(event))) => {
println!(
"[Node {}] Received gossip event: {:?}",
node_id,
std::mem::discriminant(&event)
);
if let iroh_gossip::api::Event::Received(msg) = event {
msg_count += 1;
println!(
"[Node {}] Received message #{} from gossip",
node_id, msg_count
);
match bincode::deserialize::<VersionedMessage>(&msg.content) {
Ok(versioned_msg) => {
if let Err(e) = bridge_in.push_incoming(versioned_msg) {
eprintln!(
"[Node {}] Failed to push to bridge incoming: {}",
node_id, e
);
} else {
println!(
"[Node {}] Message #{} pushed to bridge incoming",
node_id, msg_count
);
}
}
Err(e) => eprintln!(
"[Node {}] Failed to deserialize gossip message: {}",
node_id, e
),
}
}
}
Ok(Some(Err(e))) => {
eprintln!("[Node {}] Gossip receiver error: {}", node_id, e)
}
Ok(None) => {
println!("[Node {}] Gossip stream ended", node_id);
break;
}
Err(_) => {
// Timeout, no message available
}
}
}
});
}
}
// ============================================================================
// Integration Tests
// ============================================================================
/// Test: Basic cube spawn and sync (Node A spawns → Node B receives)
#[tokio::test(flavor = "multi_thread")]
async fn test_cube_spawn_and_sync() -> Result<()> {
use test_utils::*;
println!("=== Starting test_cube_spawn_and_sync ===");
// Setup contexts
println!("Creating test contexts...");
let ctx1 = TestContext::new();
let ctx2 = TestContext::new();
// Setup gossip networking
println!("Setting up gossip pair...");
let (ep1, ep2, router1, router2, bridge1, bridge2) = setup_gossip_pair().await?;
let node1_id = bridge1.node_id();
let node2_id = bridge2.node_id();
// Create headless apps
println!("Creating Bevy apps...");
let mut app1 = create_test_app(node1_id, ctx1.db_path(), bridge1);
let mut app2 = create_test_app(node2_id, ctx2.db_path(), bridge2);
println!("Apps created successfully");
println!("Node 1 ID: {}", node1_id);
println!("Node 2 ID: {}", node2_id);
// Node 1 spawns cube
let entity_id = Uuid::new_v4();
println!("Spawning cube {} on node 1", entity_id);
let spawned_entity = app1
.world_mut()
.spawn((
CubeMarker,
Transform::from_xyz(1.0, 2.0, 3.0),
GlobalTransform::default(),
NetworkedEntity::with_id(entity_id, node1_id),
NetworkedTransform,
Persisted::with_id(entity_id),
Synced,
))
.id();
// IMPORTANT: Trigger change detection for persistence
// Bevy only marks components as "changed" when mutated, not on spawn
{
let world = app1.world_mut();
if let Ok(mut entity_mut) = world.get_entity_mut(spawned_entity) {
if let Some(mut persisted) = entity_mut.get_mut::<Persisted>() {
// Dereferencing the mutable borrow triggers change detection
let _ = &mut *persisted;
}
}
}
println!("Cube spawned, triggered persistence");
println!("Cube spawned, starting sync wait...");
// Wait for cube to sync to node 2
wait_for_sync(&mut app1, &mut app2, Duration::from_secs(10), |_, w2| {
count_entities_with_id(w2, entity_id) > 0
})
.await?;
println!("Cube synced to node 2!");
// Verify cube exists on node 2 with correct Transform
let cube_transform = app2
.world_mut()
.query_filtered::<&Transform, With<CubeMarker>>()
.single(app2.world())
.expect("Cube should exist on node 2");
assert!(
(cube_transform.translation.x - 1.0).abs() < 0.01,
"Transform.x should be 1.0"
);
assert!(
(cube_transform.translation.y - 2.0).abs() < 0.01,
"Transform.y should be 2.0"
);
assert!(
(cube_transform.translation.z - 3.0).abs() < 0.01,
"Transform.z should be 3.0"
);
println!("Transform verified!");
// Cleanup
router1.shutdown().await?;
router2.shutdown().await?;
drop(ep1);
drop(ep2);
println!("=== Test completed successfully ===");
Ok(())
}