initial arhitectural overhaul

Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
This commit is contained in:
2025-12-13 22:22:05 +00:00
parent b098a19d6b
commit 5cb258fe6b
99 changed files with 4137 additions and 311 deletions

87
Cargo.lock generated
View File

@@ -227,16 +227,20 @@ dependencies = [
"bytes",
"crossbeam-channel",
"futures-lite",
"glam 0.29.3",
"iroh",
"iroh-gossip",
"lib",
"libmarathon",
"objc",
"rand 0.8.5",
"raw-window-handle",
"serde",
"tempfile",
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
"winit",
]
[[package]]
@@ -1145,7 +1149,7 @@ dependencies = [
"approx",
"bevy_reflect",
"derive_more 2.0.1",
"glam",
"glam 0.30.9",
"itertools 0.14.0",
"libm",
"rand 0.9.2",
@@ -1320,7 +1324,7 @@ dependencies = [
"downcast-rs 2.0.2",
"erased-serde",
"foldhash 0.2.0",
"glam",
"glam 0.30.9",
"inventory",
"petgraph",
"serde",
@@ -2815,7 +2819,7 @@ checksum = "02ba239319a4f60905966390f5e52799d868103a533bb7e27822792332504ddd"
dependencies = [
"const_panic",
"encase_derive",
"glam",
"glam 0.30.9",
"thiserror 2.0.17",
]
@@ -3345,6 +3349,12 @@ dependencies = [
"xml-rs",
]
[[package]]
name = "glam"
version = "0.29.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee"
[[package]]
name = "glam"
version = "0.30.9"
@@ -3628,7 +3638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29a164ceff4500f2a72b1d21beaa8aa8ad83aec2b641844c659b190cb3ea2e0b"
dependencies = [
"constgebra",
"glam",
"glam 0.30.9",
"tinyvec",
]
@@ -4466,36 +4476,6 @@ dependencies = [
"tinyvec",
]
[[package]]
name = "lib"
version = "0.1.0"
dependencies = [
"anyhow",
"bevy",
"bincode",
"blake3",
"blocking",
"chrono",
"crdts",
"criterion",
"futures-lite",
"iroh",
"iroh-gossip",
"proptest",
"rand 0.8.5",
"rusqlite",
"serde",
"serde_json",
"sha2 0.10.9",
"sync-macros",
"tempfile",
"thiserror 2.0.17",
"tokio",
"toml",
"tracing",
"uuid",
]
[[package]]
name = "libc"
version = "0.2.177"
@@ -4518,6 +4498,40 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
[[package]]
name = "libmarathon"
version = "0.1.0"
dependencies = [
"anyhow",
"bevy",
"bincode",
"blake3",
"blocking",
"bytes",
"chrono",
"crdts",
"criterion",
"futures-lite",
"glam 0.29.3",
"iroh",
"iroh-gossip",
"proptest",
"rand 0.8.5",
"raw-window-handle",
"rusqlite",
"serde",
"serde_json",
"sha2 0.10.9",
"sync-macros",
"tempfile",
"thiserror 2.0.17",
"tokio",
"toml",
"tracing",
"uuid",
"winit",
]
[[package]]
name = "libredox"
version = "0.1.10"
@@ -7014,7 +7028,7 @@ dependencies = [
"anyhow",
"bevy",
"bincode",
"lib",
"libmarathon",
"proc-macro2",
"quote",
"serde",
@@ -7959,6 +7973,7 @@ checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
dependencies = [
"dlib",
"log",
"once_cell",
"pkg-config",
]

View File

@@ -1,5 +1,5 @@
[workspace]
members = ["crates/lib", "crates/sync-macros", "crates/app"]
members = ["crates/libmarathon", "crates/sync-macros", "crates/app"]
resolver = "2"
[workspace.package]

View File

@@ -10,9 +10,8 @@ ios = []
headless = []
[dependencies]
lib = { path = "../lib" }
libmarathon = { path = "../libmarathon" }
bevy = { version = "0.17", default-features = false, features = [
"bevy_winit",
"bevy_render",
"bevy_core_pipeline",
"bevy_pbr",
@@ -21,12 +20,16 @@ bevy = { version = "0.17", default-features = false, features = [
"png",
] }
bevy_egui = "0.38"
glam = "0.29"
winit = "0.30"
raw-window-handle = "0.6"
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"] }
rand = "0.8"
iroh = { version = "0.95", features = ["discovery-local-network"] }
iroh-gossip = "0.95"
futures-lite = "2.0"
@@ -36,6 +39,7 @@ crossbeam-channel = "0.5.15"
[target.'cfg(target_os = "ios")'.dependencies]
objc = "0.2"
raw-window-handle = "0.6"
[dev-dependencies]
iroh = { version = "0.95", features = ["discovery-local-network"] }

View File

@@ -1,10 +1,13 @@
//! Cube entity management
use bevy::prelude::*;
use lib::{
use libmarathon::{
networking::{
NetworkEntityMap,
NetworkedEntity,
NetworkedSelection,
NetworkedTransform,
NodeVectorClock,
Synced,
},
persistence::Persisted,
@@ -20,53 +23,79 @@ use uuid::Uuid;
#[reflect(Component)]
pub struct CubeMarker;
/// Message to spawn a new cube at a specific position
#[derive(Message)]
pub struct SpawnCubeEvent {
pub position: Vec3,
}
/// Message to delete a cube by its network ID
#[derive(Message)]
pub struct DeleteCubeEvent {
pub entity_id: Uuid,
}
pub struct CubePlugin;
impl Plugin for CubePlugin {
fn build(&self, app: &mut App) {
app.register_type::<CubeMarker>()
.add_systems(Startup, spawn_cube);
.add_message::<SpawnCubeEvent>()
.add_message::<DeleteCubeEvent>()
.add_systems(Update, (handle_spawn_cube, handle_delete_cube));
}
}
/// Spawn the synced cube on startup
fn spawn_cube(
/// Handle cube spawn messages
fn handle_spawn_cube(
mut commands: Commands,
mut messages: MessageReader<SpawnCubeEvent>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
node_clock: Option<Res<lib::networking::NodeVectorClock>>,
node_clock: Res<NodeVectorClock>,
) {
// Wait until NodeVectorClock is available (after networking plugin initializes)
let Some(clock) = node_clock else {
warn!("NodeVectorClock not ready, deferring cube spawn");
return;
};
for event in messages.read() {
let entity_id = Uuid::new_v4();
let node_id = node_clock.node_id;
let entity_id = Uuid::new_v4();
let node_id = clock.node_id;
info!("Spawning cube {} at {:?}", entity_id, event.position);
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");
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_translation(event.position),
GlobalTransform::default(),
// Networking
NetworkedEntity::with_id(entity_id, node_id),
NetworkedTransform,
NetworkedSelection::default(),
// Persistence
Persisted::with_id(entity_id),
// Sync marker
Synced,
));
}
}
/// Handle cube delete messages
fn handle_delete_cube(
mut commands: Commands,
mut messages: MessageReader<DeleteCubeEvent>,
entity_map: Res<NetworkEntityMap>,
) {
for event in messages.read() {
if let Some(bevy_entity) = entity_map.get_entity(event.entity_id) {
info!("Deleting cube {}", event.entity_id);
commands.entity(bevy_entity).despawn();
} else {
warn!("Attempted to delete unknown cube {}", event.entity_id);
}
}
}

View File

@@ -6,11 +6,15 @@ use bevy_egui::{
EguiContexts,
EguiPrimaryContextPass,
};
use lib::networking::{
use libmarathon::networking::{
EntityLockRegistry,
GossipBridge,
NetworkedEntity,
NodeVectorClock,
};
use crate::cube::{CubeMarker, DeleteCubeEvent, SpawnCubeEvent};
pub struct DebugUiPlugin;
impl Plugin for DebugUiPlugin {
@@ -24,10 +28,10 @@ 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>,
>,
lock_registry: Option<Res<EntityLockRegistry>>,
cube_query: Query<(&Transform, &NetworkedEntity), With<CubeMarker>>,
mut spawn_events: MessageWriter<SpawnCubeEvent>,
mut delete_events: MessageWriter<DeleteCubeEvent>,
) {
let Ok(ctx) = contexts.ctx_mut() else {
return;
@@ -106,11 +110,80 @@ fn render_debug_ui(
},
}
ui.add_space(10.0);
ui.heading("Entity Controls");
ui.separator();
if ui.button(" Spawn Cube").clicked() {
spawn_events.write(SpawnCubeEvent {
position: Vec3::new(
rand::random::<f32>() * 4.0 - 2.0,
0.5,
rand::random::<f32>() * 4.0 - 2.0,
),
});
}
ui.label(format!("Total cubes: {}", cube_query.iter().count()));
// List all cubes with delete buttons
ui.add_space(5.0);
egui::ScrollArea::vertical()
.id_salt("cube_list")
.max_height(150.0)
.show(ui, |ui| {
for (_transform, networked) in cube_query.iter() {
ui.horizontal(|ui| {
ui.label(format!("Cube {:.8}...", networked.network_id));
if ui.small_button("🗑").clicked() {
delete_events.write(DeleteCubeEvent {
entity_id: networked.network_id,
});
}
});
}
});
ui.add_space(10.0);
ui.heading("Lock Status");
ui.separator();
if let (Some(lock_registry), Some(clock)) = (&lock_registry, &node_clock) {
let node_id = clock.node_id;
let locked_cubes = cube_query
.iter()
.filter(|(_, networked)| lock_registry.is_locked(networked.network_id, node_id))
.count();
ui.label(format!("Locked entities: {}", locked_cubes));
ui.add_space(5.0);
egui::ScrollArea::vertical()
.id_salt("lock_list")
.max_height(100.0)
.show(ui, |ui| {
for (_, networked) in cube_query.iter() {
let entity_id = networked.network_id;
if let Some(holder) = lock_registry.get_holder(entity_id, node_id) {
let is_ours = holder == node_id;
ui.horizontal(|ui| {
ui.label(format!("🔒 {:.8}...", entity_id));
ui.label(if is_ours { "(you)" } else { "(peer)" });
});
}
}
});
} else {
ui.label("Lock registry: Not ready");
}
ui.add_space(10.0);
ui.heading("Controls");
ui.separator();
ui.label("Left click: Select cube");
ui.label("Left drag: Move cube (XY)");
ui.label("Right drag: Rotate cube");
ui.label("Scroll: Move cube (Z)");
ui.label("ESC: Deselect");
});
}

View File

@@ -0,0 +1,124 @@
//! Bevy plugin for polling engine events and dispatching them
//!
//! This plugin bridges the gap between the tokio-based engine and Bevy's ECS.
//! It polls events from the EngineBridge every frame and dispatches them to
//! Bevy systems.
use bevy::prelude::*;
use libmarathon::{
engine::{EngineBridge, EngineCommand, EngineEvent},
networking::{CurrentSession, NetworkedEntity, NodeVectorClock, Session, SessionState, VectorClock},
};
pub struct EngineBridgePlugin;
impl Plugin for EngineBridgePlugin {
fn build(&self, app: &mut App) {
// Add the event polling system - runs every tick in Update
app.add_systems(Update, poll_engine_events);
// Detect changes and send clock tick commands to engine
app.add_systems(PostUpdate, detect_changes_and_tick);
}
}
/// Detect changes to networked entities and send tick commands to engine
///
/// Uses Bevy's change detection to detect when Transform changes on any
/// NetworkedEntity. When changes are detected, sends a TickClock command
/// to the engine, which will increment its clock and send back a ClockTicked event.
fn detect_changes_and_tick(
bridge: Res<EngineBridge>,
changed_query: Query<(), (With<NetworkedEntity>, Changed<Transform>)>,
) {
// If any networked transforms changed this frame, tick the clock
if !changed_query.is_empty() {
bridge.send_command(EngineCommand::TickClock);
}
}
/// Poll events from the engine and dispatch to Bevy
///
/// This system runs every tick and:
/// 1. Polls all available events from the EngineBridge
/// 2. Dispatches them to update Bevy resources and state
fn poll_engine_events(
mut commands: Commands,
bridge: Res<EngineBridge>,
mut current_session: Option<ResMut<CurrentSession>>,
mut node_clock: ResMut<NodeVectorClock>,
) {
let events = (*bridge).poll_events();
if !events.is_empty() {
for event in events {
match event {
EngineEvent::NetworkingStarted { session_id, node_id } => {
info!("🌐 Networking started: session={}, node={}",
session_id.to_code(), node_id);
// Create session if it doesn't exist
if current_session.is_none() {
let mut session = Session::new(session_id.clone());
session.state = SessionState::Active;
commands.insert_resource(CurrentSession::new(session, VectorClock::new()));
info!("Created new session resource: {}", session_id.to_code());
} else if let Some(ref mut session) = current_session {
// Update existing session state to Active
session.session.state = SessionState::Active;
}
// Update node ID in clock
node_clock.node_id = node_id;
}
EngineEvent::NetworkingFailed { error } => {
error!("❌ Networking failed: {}", error);
// Keep session state as Created (if session exists)
if let Some(ref mut session) = current_session {
session.session.state = SessionState::Created;
}
}
EngineEvent::NetworkingStopped => {
info!("🔌 Networking stopped");
// Update session state to Disconnected (if session exists)
if let Some(ref mut session) = current_session {
session.session.state = SessionState::Disconnected;
}
}
EngineEvent::PeerJoined { node_id } => {
info!("👋 Peer joined: {}", node_id);
// TODO(Phase 3.3): Trigger sync
}
EngineEvent::PeerLeft { node_id } => {
info!("👋 Peer left: {}", node_id);
}
EngineEvent::LockAcquired { entity_id, holder } => {
debug!("🔒 Lock acquired: entity={}, holder={}", entity_id, holder);
// TODO(Phase 3.4): Update lock visuals
}
EngineEvent::LockReleased { entity_id } => {
debug!("🔓 Lock released: entity={}", entity_id);
// TODO(Phase 3.4): Update lock visuals
}
EngineEvent::LockDenied { entity_id, current_holder } => {
debug!("⛔ Lock denied: entity={}, holder={}", entity_id, current_holder);
// TODO(Phase 3.4): Show visual feedback
}
EngineEvent::LockExpired { entity_id } => {
debug!("⏰ Lock expired: entity={}", entity_id);
// TODO(Phase 3.4): Update lock visuals
}
EngineEvent::ClockTicked { sequence, clock } => {
debug!("🕐 Clock ticked to {}", sequence);
// Update the NodeVectorClock resource with the new clock state
node_clock.clock = clock;
}
_ => {
debug!("Unhandled engine event: {:?}", event);
}
}
}
}
}

235
crates/app/src/executor.rs Normal file
View File

@@ -0,0 +1,235 @@
//! Application executor - owns winit and drives Bevy ECS
//!
//! The executor gives us full control over the event loop and allows
//! both the window and ECS to run unbounded (maximum performance).
use bevy::prelude::*;
use bevy::app::AppExit;
use bevy::input::{
ButtonInput,
mouse::MouseButton as BevyMouseButton,
keyboard::KeyCode as BevyKeyCode,
touch::{Touches, TouchInput},
};
use bevy::window::{
PrimaryWindow, WindowCreated, WindowResized, WindowScaleFactorChanged, WindowClosing,
WindowResolution, WindowMode, WindowPosition, WindowEvent as BevyWindowEvent,
RawHandleWrapper, WindowWrapper,
};
use bevy::ecs::message::Messages;
use libmarathon::engine::InputEvent;
use libmarathon::platform::desktop;
use std::sync::Arc;
use winit::application::ApplicationHandler;
use winit::event::WindowEvent as WinitWindowEvent;
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::{Window as WinitWindow, WindowId, WindowAttributes};
// Re-export InputEventBuffer from the input module
pub use crate::input::event_buffer::InputEventBuffer;
/// Application handler state machine
enum AppHandler {
Initializing { app: App },
Running {
window: Arc<WinitWindow>,
bevy_window_entity: Entity,
bevy_app: App,
},
}
impl AppHandler {
fn initialize(&mut self, event_loop: &ActiveEventLoop) {
// Only initialize if we're in the Initializing state
if !matches!(self, AppHandler::Initializing { .. }) {
return;
}
// Take ownership of the app (replace with placeholder temporarily)
let temp_state = std::mem::replace(self, AppHandler::Initializing { app: App::new() });
let AppHandler::Initializing { app } = temp_state else { unreachable!() };
let mut bevy_app = app;
// Insert InputEventBuffer resource
bevy_app.insert_resource(InputEventBuffer::default());
// Initialize window message channels
bevy_app.init_resource::<Messages<WindowCreated>>();
bevy_app.init_resource::<Messages<WindowResized>>();
bevy_app.init_resource::<Messages<WindowScaleFactorChanged>>();
bevy_app.init_resource::<Messages<WindowClosing>>();
bevy_app.init_resource::<Messages<BevyWindowEvent>>();
// Initialize input resources that Bevy UI and picking expect
bevy_app.init_resource::<ButtonInput<BevyMouseButton>>();
bevy_app.init_resource::<ButtonInput<BevyKeyCode>>();
bevy_app.init_resource::<Touches>();
bevy_app.init_resource::<Messages<TouchInput>>();
// Create the winit window BEFORE finishing the app
let window_attributes = WindowAttributes::default()
.with_title("Marathon")
.with_inner_size(winit::dpi::LogicalSize::new(1280, 720));
let winit_window = event_loop.create_window(window_attributes)
.expect("Failed to create window");
let winit_window = Arc::new(winit_window);
info!("Created window before app.finish()");
let physical_size = winit_window.inner_size();
let scale_factor = winit_window.scale_factor();
// Create window entity with all required components
let mut window = bevy::window::Window {
title: "Marathon".to_string(),
resolution: WindowResolution::new(
physical_size.width,
physical_size.height,
),
mode: WindowMode::Windowed,
position: WindowPosition::Automatic,
focused: true,
..Default::default()
};
window.resolution.set_scale_factor_override(Some(scale_factor as f32));
// Create WindowWrapper and RawHandleWrapper for renderer
let window_wrapper = WindowWrapper::new(winit_window.clone());
let raw_handle_wrapper = RawHandleWrapper::new(&window_wrapper)
.expect("Failed to create RawHandleWrapper");
let window_entity = bevy_app.world_mut().spawn((
window,
PrimaryWindow,
raw_handle_wrapper,
)).id();
info!("Created window entity {}", window_entity);
// Send WindowCreated event
bevy_app.world_mut()
.resource_mut::<Messages<WindowCreated>>()
.write(WindowCreated { window: window_entity });
// Send WindowResized event
bevy_app.world_mut()
.resource_mut::<Messages<WindowResized>>()
.write(WindowResized {
window: window_entity,
width: physical_size.width as f32 / scale_factor as f32,
height: physical_size.height as f32 / scale_factor as f32,
});
// Now finish the app - the renderer will initialize with the window
bevy_app.finish();
bevy_app.cleanup();
info!("App finished and cleaned up");
// Transition to Running state
*self = AppHandler::Running {
window: winit_window,
bevy_window_entity: window_entity,
bevy_app,
};
}
}
impl ApplicationHandler for AppHandler {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
// Initialize on first resumed() call
self.initialize(event_loop);
info!("App resumed");
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: WindowId,
event: WinitWindowEvent,
) {
// Only handle events if we're in Running state
let AppHandler::Running {
ref window,
bevy_window_entity,
ref mut bevy_app,
} = self
else {
return;
};
// Forward input events to platform bridge
desktop::push_window_event(&event);
match event {
WinitWindowEvent::CloseRequested => {
info!("Window close requested");
event_loop.exit();
}
WinitWindowEvent::Resized(physical_size) => {
// Notify Bevy of window resize
let scale_factor = window.scale_factor();
bevy_app.world_mut()
.resource_mut::<Messages<WindowResized>>()
.write(WindowResized {
window: *bevy_window_entity,
width: physical_size.width as f32 / scale_factor as f32,
height: physical_size.height as f32 / scale_factor as f32,
});
}
WinitWindowEvent::RedrawRequested => {
// Collect input events from platform bridge
let input_events = desktop::drain_as_input_events();
// Write events to InputEventBuffer resource
bevy_app.world_mut().resource_mut::<InputEventBuffer>().events = input_events;
// Run one Bevy ECS update (unbounded)
bevy_app.update();
// Check if app should exit
if let Some(exit) = bevy_app.should_exit() {
info!("App exit requested: {:?}", exit);
event_loop.exit();
}
// Clear input buffer for next frame
bevy_app.world_mut().resource_mut::<InputEventBuffer>().clear();
// Request next frame immediately (unbounded loop)
window.request_redraw();
}
_ => {}
}
}
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
// Request redraw to keep loop running
if let AppHandler::Running { ref window, .. } = self {
window.request_redraw();
}
}
}
/// Run the application executor
pub fn run(app: App) -> Result<(), Box<dyn std::error::Error>> {
let event_loop = EventLoop::new()?;
// TODO: Add battery power detection and adaptive frame/tick rate limiting
// When on battery: reduce to 60fps cap, lower ECS tick rate
// When plugged in: run unbounded for maximum performance
// Run as fast as possible (unbounded)
event_loop.set_control_flow(ControlFlow::Poll);
info!("Starting executor (unbounded mode)");
// Create handler in Initializing state
// It will transition to Running state on first resumed() callback
let mut handler = AppHandler::Initializing { app };
event_loop.run_app(&mut handler)?;
Ok(())
}

View File

@@ -0,0 +1,226 @@
//! Bridge Bevy's input to the engine's InputEvent system
//!
//! This temporarily reads Bevy's input and converts to InputEvents.
//! Later, we'll replace this with direct winit ownership.
use bevy::prelude::*;
use bevy::input::keyboard::KeyboardInput;
use bevy::input::mouse::{MouseButtonInput, MouseWheel};
use bevy::window::CursorMoved;
use libmarathon::engine::{InputEvent, KeyCode as EngineKeyCode, MouseButton as EngineMouseButton, TouchPhase, Modifiers};
/// Convert Bevy's Vec2 to glam::Vec2
///
/// Bevy re-exports glam types, so they're the same layout.
/// We just construct a new one to be safe.
#[inline]
fn to_glam_vec2(v: bevy::math::Vec2) -> glam::Vec2 {
glam::Vec2::new(v.x, v.y)
}
/// Convert Bevy's KeyCode to engine's KeyCode (winit::keyboard::KeyCode)
///
/// Bevy re-exports winit's KeyCode but wraps it, so we need to extract it.
/// For now, we'll just match the common keys. TODO: Complete mapping.
fn bevy_to_engine_keycode(bevy_key: KeyCode) -> Option<EngineKeyCode> {
// In Bevy 0.17, KeyCode variants match winit directly
// We can use format matching as a temporary solution
use EngineKeyCode as E;
Some(match bevy_key {
KeyCode::KeyA => E::KeyA,
KeyCode::KeyB => E::KeyB,
KeyCode::KeyC => E::KeyC,
KeyCode::KeyD => E::KeyD,
KeyCode::KeyE => E::KeyE,
KeyCode::KeyF => E::KeyF,
KeyCode::KeyG => E::KeyG,
KeyCode::KeyH => E::KeyH,
KeyCode::KeyI => E::KeyI,
KeyCode::KeyJ => E::KeyJ,
KeyCode::KeyK => E::KeyK,
KeyCode::KeyL => E::KeyL,
KeyCode::KeyM => E::KeyM,
KeyCode::KeyN => E::KeyN,
KeyCode::KeyO => E::KeyO,
KeyCode::KeyP => E::KeyP,
KeyCode::KeyQ => E::KeyQ,
KeyCode::KeyR => E::KeyR,
KeyCode::KeyS => E::KeyS,
KeyCode::KeyT => E::KeyT,
KeyCode::KeyU => E::KeyU,
KeyCode::KeyV => E::KeyV,
KeyCode::KeyW => E::KeyW,
KeyCode::KeyX => E::KeyX,
KeyCode::KeyY => E::KeyY,
KeyCode::KeyZ => E::KeyZ,
KeyCode::Digit1 => E::Digit1,
KeyCode::Digit2 => E::Digit2,
KeyCode::Digit3 => E::Digit3,
KeyCode::Digit4 => E::Digit4,
KeyCode::Digit5 => E::Digit5,
KeyCode::Digit6 => E::Digit6,
KeyCode::Digit7 => E::Digit7,
KeyCode::Digit8 => E::Digit8,
KeyCode::Digit9 => E::Digit9,
KeyCode::Digit0 => E::Digit0,
KeyCode::Space => E::Space,
KeyCode::Enter => E::Enter,
KeyCode::Escape => E::Escape,
KeyCode::Backspace => E::Backspace,
KeyCode::Tab => E::Tab,
KeyCode::ShiftLeft => E::ShiftLeft,
KeyCode::ShiftRight => E::ShiftRight,
KeyCode::ControlLeft => E::ControlLeft,
KeyCode::ControlRight => E::ControlRight,
KeyCode::AltLeft => E::AltLeft,
KeyCode::AltRight => E::AltRight,
KeyCode::SuperLeft => E::SuperLeft,
KeyCode::SuperRight => E::SuperRight,
KeyCode::ArrowUp => E::ArrowUp,
KeyCode::ArrowDown => E::ArrowDown,
KeyCode::ArrowLeft => E::ArrowLeft,
KeyCode::ArrowRight => E::ArrowRight,
_ => return None, // Unmapped keys
})
}
pub struct DesktopInputBridgePlugin;
impl Plugin for DesktopInputBridgePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<InputEventBuffer>()
.add_systems(PreUpdate, (
clear_buffer,
collect_mouse_buttons,
collect_mouse_motion,
collect_mouse_wheel,
collect_keyboard,
).chain());
}
}
/// Buffer for InputEvents collected this frame
#[derive(Resource, Default)]
pub struct InputEventBuffer {
pub events: Vec<InputEvent>,
}
impl InputEventBuffer {
/// Get all events from this frame
pub fn events(&self) -> &[InputEvent] {
&self.events
}
}
/// Clear the buffer at the start of each frame
fn clear_buffer(mut buffer: ResMut<InputEventBuffer>) {
buffer.events.clear();
}
/// Collect mouse button events
fn collect_mouse_buttons(
mut buffer: ResMut<InputEventBuffer>,
mut mouse_button_events: MessageReader<MouseButtonInput>,
windows: Query<&Window>,
) {
let cursor_pos = windows
.single()
.ok()
.and_then(|w| w.cursor_position())
.unwrap_or(Vec2::ZERO);
for event in mouse_button_events.read() {
let button = match event.button {
MouseButton::Left => EngineMouseButton::Left,
MouseButton::Right => EngineMouseButton::Right,
MouseButton::Middle => EngineMouseButton::Middle,
_ => continue,
};
let phase = if event.state.is_pressed() {
TouchPhase::Started
} else {
TouchPhase::Ended
};
buffer.events.push(InputEvent::Mouse {
pos: to_glam_vec2(cursor_pos),
button,
phase,
});
}
}
/// Collect mouse motion events (for drag tracking)
fn collect_mouse_motion(
mut buffer: ResMut<InputEventBuffer>,
mut cursor_moved: MessageReader<CursorMoved>,
mouse_buttons: Res<ButtonInput<MouseButton>>,
) {
// Only process if cursor actually moved
for event in cursor_moved.read() {
let cursor_pos = event.position;
// Generate drag events for currently pressed buttons
if mouse_buttons.pressed(MouseButton::Left) {
buffer.events.push(InputEvent::Mouse {
pos: to_glam_vec2(cursor_pos),
button: EngineMouseButton::Left,
phase: TouchPhase::Moved,
});
}
if mouse_buttons.pressed(MouseButton::Right) {
buffer.events.push(InputEvent::Mouse {
pos: to_glam_vec2(cursor_pos),
button: EngineMouseButton::Right,
phase: TouchPhase::Moved,
});
}
}
}
/// Collect mouse wheel events
fn collect_mouse_wheel(
mut buffer: ResMut<InputEventBuffer>,
mut wheel_events: MessageReader<MouseWheel>,
windows: Query<&Window>,
) {
let cursor_pos = windows
.single()
.ok()
.and_then(|w| w.cursor_position())
.unwrap_or(Vec2::ZERO);
for event in wheel_events.read() {
buffer.events.push(InputEvent::MouseWheel {
delta: to_glam_vec2(Vec2::new(event.x, event.y)),
pos: to_glam_vec2(cursor_pos),
});
}
}
/// Collect keyboard events
fn collect_keyboard(
mut buffer: ResMut<InputEventBuffer>,
mut keyboard_events: MessageReader<KeyboardInput>,
keys: Res<ButtonInput<KeyCode>>,
) {
for event in keyboard_events.read() {
let modifiers = Modifiers {
shift: keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]),
ctrl: keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]),
alt: keys.any_pressed([KeyCode::AltLeft, KeyCode::AltRight]),
meta: keys.any_pressed([KeyCode::SuperLeft, KeyCode::SuperRight]),
};
// Convert Bevy's KeyCode to engine's KeyCode
if let Some(engine_key) = bevy_to_engine_keycode(event.key_code) {
buffer.events.push(InputEvent::Keyboard {
key: engine_key,
pressed: event.state.is_pressed(),
modifiers,
});
}
}
}

View File

@@ -0,0 +1,22 @@
//! Input event buffer shared between executor and ECS
use bevy::prelude::*;
use libmarathon::engine::InputEvent;
/// Input event buffer resource for Bevy ECS
#[derive(Resource, Default)]
pub struct InputEventBuffer {
pub events: Vec<InputEvent>,
}
impl InputEventBuffer {
/// Get all events from this frame
pub fn events(&self) -> &[InputEvent] {
&self.events
}
/// Clear the buffer
pub fn clear(&mut self) {
self.events.clear();
}
}

View File

@@ -0,0 +1,152 @@
//! Input handling using engine GameActions
//!
//! Processes GameActions (from InputController) and applies them to game entities.
use bevy::prelude::*;
use libmarathon::{
engine::{GameAction, InputController},
networking::{EntityLockRegistry, NetworkedEntity, NodeVectorClock},
};
use super::event_buffer::InputEventBuffer;
pub struct InputHandlerPlugin;
impl Plugin for InputHandlerPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<InputControllerResource>()
.add_systems(Update, handle_game_actions);
}
}
/// Resource wrapping the InputController
#[derive(Resource)]
struct InputControllerResource {
controller: InputController,
}
impl Default for InputControllerResource {
fn default() -> Self {
Self {
controller: InputController::new(),
}
}
}
/// Convert glam::Vec2 to Bevy's Vec2
///
/// They're the same type, just construct a new one.
#[inline]
fn to_bevy_vec2(v: glam::Vec2) -> bevy::math::Vec2 {
bevy::math::Vec2::new(v.x, v.y)
}
/// Process GameActions and apply to entities
fn handle_game_actions(
input_buffer: Res<InputEventBuffer>,
mut controller_res: ResMut<InputControllerResource>,
lock_registry: Res<EntityLockRegistry>,
node_clock: Res<NodeVectorClock>,
mut cube_query: Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
) {
let node_id = node_clock.node_id;
// Process all input events through the controller to get game actions
let mut all_actions = Vec::new();
for event in input_buffer.events() {
let actions = controller_res.controller.process_event(event);
all_actions.extend(actions);
}
// Apply game actions to entities
for action in all_actions {
match action {
GameAction::MoveEntity { delta } => {
apply_move_entity(delta, &lock_registry, node_id, &mut cube_query);
}
GameAction::RotateEntity { delta } => {
apply_rotate_entity(delta, &lock_registry, node_id, &mut cube_query);
}
GameAction::MoveEntityDepth { delta } => {
apply_move_depth(delta, &lock_registry, node_id, &mut cube_query);
}
GameAction::ResetEntity => {
apply_reset_entity(&lock_registry, node_id, &mut cube_query);
}
_ => {
// Other actions not yet implemented
}
}
}
}
/// Apply MoveEntity action to locked cubes
fn apply_move_entity(
delta: glam::Vec2,
lock_registry: &EntityLockRegistry,
node_id: uuid::Uuid,
cube_query: &mut Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
) {
let bevy_delta = to_bevy_vec2(delta);
let sensitivity = 0.01; // Scale factor
for (networked, mut transform) in cube_query.iter_mut() {
if lock_registry.is_locked_by(networked.network_id, node_id, node_id) {
transform.translation.x += bevy_delta.x * sensitivity;
transform.translation.y -= bevy_delta.y * sensitivity; // Invert Y for screen coords
}
}
}
/// Apply RotateEntity action to locked cubes
fn apply_rotate_entity(
delta: glam::Vec2,
lock_registry: &EntityLockRegistry,
node_id: uuid::Uuid,
cube_query: &mut Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
) {
let bevy_delta = to_bevy_vec2(delta);
let sensitivity = 0.01;
for (networked, mut transform) in cube_query.iter_mut() {
if lock_registry.is_locked_by(networked.network_id, node_id, node_id) {
let rotation_x = Quat::from_rotation_y(bevy_delta.x * sensitivity);
let rotation_y = Quat::from_rotation_x(-bevy_delta.y * sensitivity);
transform.rotation = rotation_x * transform.rotation * rotation_y;
}
}
}
/// Apply MoveEntityDepth action to locked cubes
fn apply_move_depth(
delta: f32,
lock_registry: &EntityLockRegistry,
node_id: uuid::Uuid,
cube_query: &mut Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
) {
let sensitivity = 0.1;
for (networked, mut transform) in cube_query.iter_mut() {
if lock_registry.is_locked_by(networked.network_id, node_id, node_id) {
transform.translation.z += delta * sensitivity;
}
}
}
/// Apply ResetEntity action to locked cubes
fn apply_reset_entity(
lock_registry: &EntityLockRegistry,
node_id: uuid::Uuid,
cube_query: &mut Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
) {
for (networked, mut transform) in cube_query.iter_mut() {
if lock_registry.is_locked_by(networked.network_id, node_id, node_id) {
transform.translation = Vec3::ZERO;
transform.rotation = Quat::IDENTITY;
}
}
}

View File

@@ -0,0 +1,28 @@
//! Input handling modules
//!
//! This module contains platform-specific input adapters that bridge
//! native input (Bevy/winit, iOS pencil) to libmarathon's InputEvent system.
pub mod event_buffer;
pub mod input_handler;
#[cfg(target_os = "ios")]
pub mod pencil;
#[cfg(not(target_os = "ios"))]
pub mod desktop_bridge;
#[cfg(not(target_os = "ios"))]
pub mod mouse;
pub use event_buffer::InputEventBuffer;
pub use input_handler::InputHandlerPlugin;
#[cfg(target_os = "ios")]
pub use pencil::PencilInputPlugin;
#[cfg(not(target_os = "ios"))]
pub use desktop_bridge::DesktopInputBridgePlugin;
#[cfg(not(target_os = "ios"))]
pub use mouse::MouseInputPlugin;

View File

@@ -2,6 +2,7 @@
use bevy::prelude::*;
use bevy::input::mouse::{MouseMotion, MouseWheel};
use libmarathon::networking::{EntityLockRegistry, NetworkedEntity, NodeVectorClock};
pub struct MouseInputPlugin;
@@ -20,13 +21,15 @@ struct MouseState {
right_pressed: bool,
}
/// Handle mouse input to move and rotate the cube
/// Handle mouse input to move and rotate cubes that are locked by us
fn handle_mouse_input(
mouse_buttons: Res<ButtonInput<MouseButton>>,
mut mouse_motion: EventReader<MouseMotion>,
mut mouse_wheel: EventReader<MouseWheel>,
mut mouse_motion: MessageReader<MouseMotion>,
mut mouse_wheel: MessageReader<MouseWheel>,
mut mouse_state: Local<Option<MouseState>>,
mut cube_query: Query<&mut Transform, With<crate::cube::CubeMarker>>,
lock_registry: Res<EntityLockRegistry>,
node_clock: Res<NodeVectorClock>,
mut cube_query: Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
) {
// Initialize mouse state if needed
if mouse_state.is_none() {
@@ -38,42 +41,57 @@ fn handle_mouse_input(
state.left_pressed = mouse_buttons.pressed(MouseButton::Left);
state.right_pressed = mouse_buttons.pressed(MouseButton::Right);
let node_id = node_clock.node_id;
// 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
// Process mouse motion - only for cubes locked by us
if total_delta != Vec2::ZERO {
for mut transform in cube_query.iter_mut() {
for (networked, mut transform) in cube_query.iter_mut() {
// Only move cubes that we have locked
if !lock_registry.is_locked_by(networked.network_id, node_id, node_id) {
continue;
}
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
// Change detection will trigger clock tick automatically
} 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;
// Change detection will trigger clock tick automatically
}
}
}
// Process mouse wheel for Z-axis movement
// Process mouse wheel for Z-axis movement - only for cubes locked by us
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() {
for (networked, mut transform) in cube_query.iter_mut() {
// Only move cubes that we have locked
if !lock_registry.is_locked_by(networked.network_id, node_id, node_id) {
continue;
}
// Scroll: Move in Z axis
let sensitivity = 0.1;
transform.translation.z += total_scroll * sensitivity;
// Change detection will trigger clock tick automatically
}
}
}

View File

@@ -0,0 +1,69 @@
//! Apple Pencil input system for iOS
//!
//! This module integrates the platform-agnostic pencil bridge with Bevy.
use bevy::prelude::*;
use libmarathon::{engine::InputEvent, platform::ios};
pub struct PencilInputPlugin;
impl Plugin for PencilInputPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, attach_pencil_capture)
.add_systems(PreUpdate, poll_pencil_input);
}
}
/// Resource to track the latest pencil state
#[derive(Resource, Default)]
pub struct PencilState {
pub latest: Option<InputEvent>,
pub points_this_frame: usize,
}
/// Attach the Swift pencil capture to Bevy's window
#[cfg(target_os = "ios")]
fn attach_pencil_capture(windows: Query<&bevy::window::RawHandleWrapper, With<bevy::window::PrimaryWindow>>) {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
let Ok(handle) = windows.get_single() else {
warn!("No primary window for pencil capture");
return;
};
unsafe {
if let Ok(raw) = handle.window_handle() {
if let RawWindowHandle::UiKit(h) = raw.as_ref() {
ios::swift_attach_pencil_capture(h.ui_view.as_ptr() as *mut _);
info!("✏️ Apple Pencil capture attached");
}
}
}
}
#[cfg(not(target_os = "ios"))]
fn attach_pencil_capture() {
// No-op on non-iOS platforms
}
/// Poll pencil input from the platform layer and update PencilState
fn poll_pencil_input(mut commands: Commands, state: Option<ResMut<PencilState>>) {
let events = ios::drain_as_input_events();
if events.is_empty() {
return;
}
// Insert resource if it doesn't exist
if state.is_none() {
commands.insert_resource(PencilState::default());
return;
}
if let Some(mut state) = state {
state.points_this_frame = events.len();
if let Some(latest) = events.last() {
state.latest = Some(*latest);
}
}
}

View File

@@ -6,7 +6,10 @@
pub mod camera;
pub mod cube;
pub mod debug_ui;
pub mod engine_bridge;
pub mod input;
pub mod rendering;
pub mod setup;
pub use cube::CubeMarker;
pub use engine_bridge::EngineBridgePlugin;

View File

@@ -3,38 +3,36 @@
//! This demonstrates real-time CRDT synchronization with Apple Pencil input.
use bevy::prelude::*;
use bevy_egui::EguiPlugin;
use lib::{
networking::{NetworkingConfig, NetworkingPlugin},
// use bevy_egui::EguiPlugin; // Disabled - needs WinitPlugin which we own directly
use libmarathon::{
engine::{EngineBridge, EngineCore},
persistence::{PersistenceConfig, PersistencePlugin},
};
use std::path::PathBuf;
use uuid::Uuid;
mod camera;
mod cube;
mod debug_ui;
mod executor;
mod engine_bridge;
mod rendering;
mod selection;
mod session;
mod session_ui;
mod setup;
#[cfg(not(target_os = "ios"))]
mod input {
pub mod mouse;
pub use mouse::MouseInputPlugin;
}
use engine_bridge::EngineBridgePlugin;
#[cfg(target_os = "ios")]
mod input {
pub mod pencil;
pub use pencil::PencilInputPlugin;
}
mod input;
use camera::*;
use cube::*;
use debug_ui::*;
use input::*;
use rendering::*;
use setup::*;
use selection::*;
use session::*;
use session_ui::*;
fn main() {
// Initialize logging
@@ -46,56 +44,62 @@ fn main() {
)
.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");
let db_path_str = db_path.to_str().unwrap().to_string();
// 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();
// Create EngineBridge (for communication between Bevy and EngineCore)
let (engine_bridge, engine_handle) = EngineBridge::new();
info!("EngineBridge created");
// Spawn EngineCore on tokio runtime (runs in background thread)
std::thread::spawn(move || {
info!("Starting EngineCore on tokio runtime...");
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let core = EngineCore::new(engine_handle, &db_path_str);
core.run().await;
});
});
info!("EngineCore spawned in background");
// Create Bevy app (without winit - we own the event loop)
let mut app = App::new();
// Insert EngineBridge as a resource for Bevy systems to use
app.insert_resource(engine_bridge);
// Use DefaultPlugins but disable winit/window/input (we own those)
app.add_plugins(
DefaultPlugins
.build()
.disable::<bevy::log::LogPlugin>() // Using tracing-subscriber
.disable::<bevy::winit::WinitPlugin>() // We own winit
.disable::<bevy::window::WindowPlugin>() // We own the window
.disable::<bevy::input::InputPlugin>() // We provide InputEvents directly
.disable::<bevy::gilrs::GilrsPlugin>() // We handle gamepad input ourselves
);
// app.add_plugins(EguiPlugin::default()); // Disabled - needs WinitPlugin
app.add_plugins(EngineBridgePlugin);
app.add_plugins(PersistencePlugin::with_config(
db_path,
PersistenceConfig {
flush_interval_secs: 2,
checkpoint_interval_secs: 30,
battery_adaptive: true,
..Default::default()
},
));
app.add_plugins(CameraPlugin);
app.add_plugins(RenderingPlugin);
app.add_plugins(input::InputHandlerPlugin);
app.add_plugins(CubePlugin);
app.add_plugins(SelectionPlugin);
// app.add_plugins(DebugUiPlugin); // Disabled - uses egui
// app.add_plugins(SessionUiPlugin); // Disabled - uses egui
app.add_systems(Startup, initialize_offline_resources);
// Run with our executor (unbounded event loop)
executor::run(app).expect("Failed to run executor");
}

217
crates/app/src/selection.rs Normal file
View File

@@ -0,0 +1,217 @@
//! Entity selection and lock acquisition
//!
//! Handles clicking/tapping on entities to select them, acquiring locks,
//! and providing visual feedback based on lock state.
use bevy::prelude::*;
use libmarathon::networking::{
EntityLockRegistry,
GossipBridge,
LockMessage,
NetworkedEntity,
NetworkedSelection,
NodeVectorClock,
SyncMessage,
VersionedMessage,
};
use uuid::Uuid;
use crate::cube::CubeMarker;
pub struct SelectionPlugin;
impl Plugin for SelectionPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
(
handle_entity_click,
handle_deselect_key,
update_lock_visuals,
)
.chain(),
);
}
}
/// System to handle clicking/tapping on entities to select and acquire locks
fn handle_entity_click(
mouse_button: Res<ButtonInput<MouseButton>>,
windows: Query<&Window>,
cameras: Query<(&Camera, &GlobalTransform)>,
cubes: Query<(Entity, &Transform, &NetworkedEntity), With<CubeMarker>>,
mut selections: Query<&mut NetworkedSelection>,
mut lock_registry: ResMut<EntityLockRegistry>,
node_clock: Res<NodeVectorClock>,
bridge: Option<Res<GossipBridge>>,
) {
// Only on left click press
if !mouse_button.just_pressed(MouseButton::Left) {
return;
}
let Ok(window) = windows.single() else {
return;
};
let Some(cursor_pos) = window.cursor_position() else {
return;
};
let Ok((camera, cam_transform)) = cameras.single() else {
return;
};
// Cast ray from cursor
let Ok(ray) = camera.viewport_to_world(cam_transform, cursor_pos) else {
return;
};
// Find closest cube that intersects ray
let mut closest: Option<(f32, Entity, Uuid)> = None;
for (entity, transform, networked) in cubes.iter() {
// Simple sphere collision (approximate cube as sphere with radius ~0.7)
let to_cube = transform.translation - ray.origin;
let t = to_cube.dot(*ray.direction);
if t < 0.0 {
continue;
}
let closest_point = ray.origin + *ray.direction * t;
let distance = (closest_point - transform.translation).length();
if distance < 0.7 {
// Hit!
if let Some((best_dist, _, _)) = closest {
if t < best_dist {
closest = Some((t, entity, networked.network_id));
}
} else {
closest = Some((t, entity, networked.network_id));
}
}
}
// Process result
if let Some((_, bevy_entity, entity_id)) = closest {
// Clicked on a cube - try to acquire lock
match lock_registry.try_acquire(entity_id, node_clock.node_id) {
Ok(()) => {
info!("Lock acquired for {}", entity_id);
// Update selection component
if let Ok(mut selection) = selections.get_mut(bevy_entity) {
selection.selected_ids.clear(); // Clear previous selections
selection.selected_ids.insert(entity_id);
}
// Broadcast LockRequest to other nodes
if let Some(bridge) = bridge.as_ref() {
let msg = VersionedMessage::new(SyncMessage::Lock(LockMessage::LockRequest {
entity_id,
node_id: node_clock.node_id,
}));
if let Err(e) = bridge.send(msg) {
error!("Failed to broadcast lock request: {}", e);
}
}
}
Err(e) => {
warn!("Failed to acquire lock for {}: {}", entity_id, e);
}
}
} else {
// Clicked on empty space - deselect all and release locks
for mut selection in selections.iter_mut() {
// Release all locks we're holding
for entity_id in selection.selected_ids.iter() {
lock_registry.release(*entity_id, node_clock.node_id);
info!("Released lock for {} (clicked away)", entity_id);
// Broadcast LockRelease to other nodes
if let Some(bridge) = bridge.as_ref() {
let msg = VersionedMessage::new(SyncMessage::Lock(LockMessage::LockRelease {
entity_id: *entity_id,
node_id: node_clock.node_id,
}));
if let Err(e) = bridge.send(msg) {
error!("Failed to broadcast lock release: {}", e);
}
}
}
selection.selected_ids.clear();
}
}
}
/// System to handle ESC key for deselection
fn handle_deselect_key(
keyboard: Res<ButtonInput<KeyCode>>,
mut selections: Query<&mut NetworkedSelection>,
mut lock_registry: ResMut<EntityLockRegistry>,
node_clock: Res<NodeVectorClock>,
bridge: Option<Res<GossipBridge>>,
) {
if keyboard.just_pressed(KeyCode::Escape) {
for mut selection in selections.iter_mut() {
if !selection.selected_ids.is_empty() {
info!("Deselecting {} entities via ESC key", selection.selected_ids.len());
// Release all locks we're holding
for entity_id in selection.selected_ids.iter() {
lock_registry.release(*entity_id, node_clock.node_id);
info!("Released lock for {} (ESC key)", entity_id);
// Broadcast LockRelease to other nodes
if let Some(bridge) = bridge.as_ref() {
let msg = VersionedMessage::new(SyncMessage::Lock(LockMessage::LockRelease {
entity_id: *entity_id,
node_id: node_clock.node_id,
}));
if let Err(e) = bridge.send(msg) {
error!("Failed to broadcast lock release: {}", e);
}
}
}
selection.selected_ids.clear();
}
}
}
}
/// System to update visual appearance based on lock state
///
/// Color scheme:
/// - Green: Locked by us (we can edit)
/// - Red: Locked by someone else (they can edit, we can't)
/// - Pink: Not locked (nobody is editing)
fn update_lock_visuals(
lock_registry: Res<EntityLockRegistry>,
node_clock: Res<NodeVectorClock>,
mut cubes: Query<(&NetworkedEntity, &mut MeshMaterial3d<StandardMaterial>), With<CubeMarker>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
for (networked, material_handle) in cubes.iter_mut() {
let entity_id = networked.network_id;
// Determine color based on lock state
let node_id = node_clock.node_id;
let color = if lock_registry.is_locked_by(entity_id, node_id, node_id) {
// Locked by us - green
Color::srgb(0.3, 0.8, 0.3)
} else if lock_registry.is_locked(entity_id, node_id) {
// Locked by someone else - red
Color::srgb(0.8, 0.3, 0.3)
} else {
// Not locked - default pink
Color::srgb(0.8, 0.3, 0.6)
};
// Update material color
if let Some(mat) = materials.get_mut(&material_handle.0) {
mat.base_color = color;
}
}
}

36
crates/app/src/session.rs Normal file
View File

@@ -0,0 +1,36 @@
//! App-level offline resource management
//!
//! Sets up vector clock and networking resources. Sessions are created later
//! when the user starts networking.
use bevy::prelude::*;
use libmarathon::{
networking::{
EntityLockRegistry, NetworkEntityMap, NodeVectorClock, VectorClock,
},
};
use uuid::Uuid;
/// Initialize offline resources on app startup
///
/// This sets up the vector clock and networking-related resources, but does NOT
/// create a session. Sessions only exist when networking is active.
pub fn initialize_offline_resources(world: &mut World) {
info!("Initializing offline resources (no session yet)...");
// Create node ID (persists for this app instance)
let node_id = Uuid::new_v4();
info!("Node ID: {}", node_id);
// Insert vector clock resource (always available, offline or online)
world.insert_resource(NodeVectorClock {
node_id,
clock: VectorClock::new(),
});
// Insert networking resources (available from startup, even before networking starts)
world.insert_resource(NetworkEntityMap::default());
world.insert_resource(EntityLockRegistry::default());
info!("Offline resources initialized (vector clock ready)");
}

View File

@@ -0,0 +1,141 @@
//! Session UI panel
//!
//! Displays current session code, allows joining different sessions,
//! and shows connected peer information.
use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass};
use libmarathon::{
engine::{EngineBridge, EngineCommand},
networking::{CurrentSession, NodeVectorClock, SessionId},
};
pub struct SessionUiPlugin;
impl Plugin for SessionUiPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SessionUiState>()
.add_systems(EguiPrimaryContextPass, session_ui_panel);
}
}
#[derive(Resource, Default)]
struct SessionUiState {
join_code_input: String,
show_join_dialog: bool,
}
fn session_ui_panel(
mut contexts: EguiContexts,
mut ui_state: ResMut<SessionUiState>,
current_session: Option<Res<CurrentSession>>,
node_clock: Option<Res<NodeVectorClock>>,
bridge: Res<EngineBridge>,
) {
let Ok(ctx) = contexts.ctx_mut() else {
return;
};
egui::Window::new("Session")
.default_pos([320.0, 10.0])
.default_width(280.0)
.show(ctx, |ui| {
if let Some(session) = current_session.as_ref() {
// ONLINE MODE: Session exists, networking is active
ui.heading("Session (Online)");
ui.separator();
ui.horizontal(|ui| {
ui.label("Code:");
ui.code(session.session.id.to_code());
if ui.small_button("📋").clicked() {
// TODO: Copy to clipboard (requires clipboard API)
info!("Session code: {}", session.session.id.to_code());
}
});
ui.label(format!("State: {:?}", session.session.state));
if let Some(clock) = node_clock.as_ref() {
ui.label(format!("Connected nodes: {}", clock.clock.clocks.len()));
}
ui.add_space(10.0);
// Stop networking button
if ui.button("🔌 Stop Networking").clicked() {
info!("Stopping networking");
bridge.send_command(EngineCommand::StopNetworking);
}
} else {
// OFFLINE MODE: No session, networking not started
ui.heading("Offline Mode");
ui.separator();
ui.label("World is running offline");
ui.label("Vector clock is tracking changes");
if let Some(clock) = node_clock.as_ref() {
let current_seq = clock.clock.clocks.get(&clock.node_id).copied().unwrap_or(0);
ui.label(format!("Local sequence: {}", current_seq));
}
ui.add_space(10.0);
// Start networking button
if ui.button("🌐 Start Networking").clicked() {
info!("Starting networking (will create new session)");
// Generate a new session ID on the fly
let new_session_id = libmarathon::networking::SessionId::new();
info!("New session code: {}", new_session_id.to_code());
bridge.send_command(EngineCommand::StartNetworking {
session_id: new_session_id,
});
}
ui.add_space(5.0);
// Join existing session button
if ui.button(" Join Session").clicked() {
ui_state.show_join_dialog = true;
}
}
});
// Join dialog (using same context)
if ui_state.show_join_dialog {
egui::Window::new("Join Session")
.collapsible(false)
.show(ctx, |ui| {
ui.label("Enter session code (abc-def-123):");
ui.text_edit_singleline(&mut ui_state.join_code_input);
ui.add_space(5.0);
ui.label("Note: Joining requires app restart");
ui.add_space(10.0);
ui.horizontal(|ui| {
if ui.button("Join").clicked() {
match SessionId::from_code(&ui_state.join_code_input) {
Ok(session_id) => {
info!("Joining session: {} → {}", ui_state.join_code_input, session_id);
bridge.send_command(EngineCommand::JoinSession {
session_id,
});
ui_state.show_join_dialog = false;
ui_state.join_code_input.clear();
}
Err(e) => {
error!("Invalid session code '{}': {:?}", ui_state.join_code_input, e);
}
}
}
if ui.button("Cancel").clicked() {
ui_state.show_join_dialog = false;
ui_state.join_code_input.clear();
}
});
});
}
}

View File

@@ -49,7 +49,7 @@
use anyhow::Result;
use bevy::prelude::*;
use lib::networking::{GossipBridge, SessionId};
use libmarathon::networking::{GossipBridge, SessionId};
use uuid::Uuid;
/// Session ID to use for network initialization
@@ -271,7 +271,7 @@ fn spawn_bridge_tasks(
use bytes::Bytes;
use futures_lite::StreamExt;
use lib::networking::VersionedMessage;
use libmarathon::networking::VersionedMessage;
let node_id = bridge.node_id();

View File

@@ -36,7 +36,7 @@ use iroh_gossip::{
net::Gossip,
proto::TopicId,
};
use lib::{
use libmarathon::{
networking::{
GossipBridge,
NetworkedEntity,

View File

@@ -1,5 +1,5 @@
[package]
name = "lib"
name = "libmarathon"
version = "0.1.0"
edition.workspace = true
@@ -16,13 +16,19 @@ uuid = { version = "1.0", features = ["v4", "serde"] }
toml.workspace = true
tracing.workspace = true
bevy.workspace = true
glam = "0.29"
winit = "0.30"
raw-window-handle = "0.6"
bincode = "1.3"
bytes = "1.0"
futures-lite = "2.0"
sha2 = "0.10"
blake3 = "1.5"
rand = "0.8"
tokio.workspace = true
blocking = "1.6"
iroh = { workspace = true, features = ["discovery-local-network"] }
iroh-gossip.workspace = true
[dev-dependencies]
tokio.workspace = true

View File

@@ -10,7 +10,7 @@ use criterion::{
criterion_group,
criterion_main,
};
use lib::networking::VectorClock;
use libmarathon::networking::VectorClock;
/// Helper to create a vector clock with N nodes
fn create_clock_with_nodes(num_nodes: usize) -> VectorClock {

View File

@@ -10,7 +10,7 @@ use criterion::{
criterion_group,
criterion_main,
};
use lib::persistence::{
use libmarathon::persistence::{
PersistenceOp,
WriteBuffer,
};

View File

@@ -0,0 +1,72 @@
//! Bridge between Bevy and Core Engine
//!
//! TODO(Phase 3): Create a Bevy-specific system (in app crate) that polls
//! `EngineBridge::poll_events()` every tick and dispatches EngineEvents to Bevy
//! (spawn entities, update transforms, update locks, emit Bevy messages, etc.)
//!
//! NOTE: The bridge is ECS-agnostic. Later we can create adapters for other engines
//! like Flecs once we're closer to release.
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use bevy::prelude::Resource;
use super::{EngineCommand, EngineEvent};
/// Shared bridge between Bevy and Core Engine
#[derive(Clone, Resource)]
pub struct EngineBridge {
command_tx: mpsc::UnboundedSender<EngineCommand>,
event_rx: Arc<Mutex<mpsc::UnboundedReceiver<EngineEvent>>>,
}
/// Engine-side handle for receiving commands and sending events
pub struct EngineHandle {
pub(crate) command_rx: mpsc::UnboundedReceiver<EngineCommand>,
pub(crate) event_tx: mpsc::UnboundedSender<EngineEvent>,
}
impl EngineBridge {
/// Create a new bridge and return both the Bevy-side bridge and Engine-side handle
pub fn new() -> (Self, EngineHandle) {
let (command_tx, command_rx) = mpsc::unbounded_channel();
let (event_tx, event_rx) = mpsc::unbounded_channel();
let bridge = Self {
command_tx,
event_rx: Arc::new(Mutex::new(event_rx)),
};
let handle = EngineHandle {
command_rx,
event_tx,
};
(bridge, handle)
}
/// Send command from Bevy to Engine
pub fn send_command(&self, cmd: EngineCommand) {
// Ignore send errors (engine might be shut down)
let _ = self.command_tx.send(cmd);
}
/// Poll events from Engine to Bevy (non-blocking)
/// Returns all available events in the queue
pub fn poll_events(&self) -> Vec<EngineEvent> {
let mut events = Vec::new();
// Try to lock without blocking (returns immediately if locked)
if let Ok(mut rx) = self.event_rx.try_lock() {
while let Ok(event) = rx.try_recv() {
events.push(event);
}
}
events
}
}
impl Default for EngineBridge {
fn default() -> Self {
Self::new().0
}
}

View File

@@ -0,0 +1,50 @@
//! Commands sent from Bevy to the Core Engine
use crate::networking::SessionId;
use bevy::prelude::*;
use uuid::Uuid;
/// Commands that Bevy sends to the Core Engine
#[derive(Debug, Clone)]
pub enum EngineCommand {
// Networking lifecycle
StartNetworking { session_id: SessionId },
StopNetworking,
JoinSession { session_id: SessionId },
LeaveSession,
// CRDT operations
SpawnEntity {
entity_id: Uuid,
position: Vec3,
rotation: Quat,
},
UpdateTransform {
entity_id: Uuid,
position: Vec3,
rotation: Quat,
},
DeleteEntity {
entity_id: Uuid,
},
// Lock operations
AcquireLock {
entity_id: Uuid,
},
ReleaseLock {
entity_id: Uuid,
},
BroadcastHeartbeat {
entity_id: Uuid,
},
// Persistence
SaveSession,
LoadSession {
session_id: SessionId,
},
// Clock
TickClock,
}

View File

@@ -0,0 +1,140 @@
//! Core Engine event loop - runs on tokio outside Bevy
use tokio::task::JoinHandle;
use uuid::Uuid;
use super::{EngineCommand, EngineEvent, EngineHandle, NetworkingManager, PersistenceManager};
use crate::networking::{SessionId, VectorClock};
pub struct EngineCore {
handle: EngineHandle,
networking_task: Option<JoinHandle<()>>,
#[allow(dead_code)]
persistence: PersistenceManager,
// Clock state
node_id: Uuid,
clock: VectorClock,
}
impl EngineCore {
pub fn new(handle: EngineHandle, db_path: &str) -> Self {
let persistence = PersistenceManager::new(db_path);
let node_id = Uuid::new_v4();
let clock = VectorClock::new();
tracing::info!("EngineCore node ID: {}", node_id);
Self {
handle,
networking_task: None, // Start offline
persistence,
node_id,
clock,
}
}
/// Start the engine event loop (runs on tokio)
/// Processes commands unbounded - tokio handles internal polling
pub async fn run(mut self) {
tracing::info!("EngineCore starting (unbounded)...");
// Process commands as they arrive
while let Some(cmd) = self.handle.command_rx.recv().await {
self.handle_command(cmd).await;
}
tracing::info!("EngineCore shutting down (command channel closed)");
}
async fn handle_command(&mut self, cmd: EngineCommand) {
match cmd {
EngineCommand::StartNetworking { session_id } => {
self.start_networking(session_id).await;
}
EngineCommand::StopNetworking => {
self.stop_networking().await;
}
EngineCommand::JoinSession { session_id } => {
self.join_session(session_id).await;
}
EngineCommand::LeaveSession => {
self.stop_networking().await;
}
EngineCommand::SaveSession => {
// TODO: Save current session state
tracing::debug!("SaveSession command received (stub)");
}
EngineCommand::LoadSession { session_id } => {
tracing::debug!("LoadSession command received for {} (stub)", session_id.to_code());
}
EngineCommand::TickClock => {
self.tick_clock();
}
// TODO: Handle CRDT and lock commands in Phase 2
_ => {
tracing::debug!("Unhandled command: {:?}", cmd);
}
}
}
fn tick_clock(&mut self) {
let seq = self.clock.increment(self.node_id);
let _ = self.handle.event_tx.send(EngineEvent::ClockTicked {
sequence: seq,
clock: self.clock.clone(),
});
tracing::debug!("Clock ticked to {}", seq);
}
async fn start_networking(&mut self, session_id: SessionId) {
if self.networking_task.is_some() {
tracing::warn!("Networking already started");
return;
}
match NetworkingManager::new(session_id.clone()).await {
Ok(net_manager) => {
let node_id = net_manager.node_id();
// Spawn NetworkingManager in background task
let event_tx = self.handle.event_tx.clone();
let task = tokio::spawn(async move {
net_manager.run(event_tx).await;
});
self.networking_task = Some(task);
let _ = self.handle.event_tx.send(EngineEvent::NetworkingStarted {
session_id: session_id.clone(),
node_id,
});
tracing::info!("Networking started for session {}", session_id.to_code());
}
Err(e) => {
let _ = self.handle.event_tx.send(EngineEvent::NetworkingFailed {
error: e.to_string(),
});
tracing::error!("Failed to start networking: {}", e);
}
}
}
async fn stop_networking(&mut self) {
if let Some(task) = self.networking_task.take() {
task.abort(); // Cancel the networking task
let _ = self.handle.event_tx.send(EngineEvent::NetworkingStopped);
tracing::info!("Networking stopped");
}
}
async fn join_session(&mut self, session_id: SessionId) {
// Stop existing networking if any
if self.networking_task.is_some() {
self.stop_networking().await;
}
// Start networking with new session
self.start_networking(session_id).await;
}
}

View File

@@ -0,0 +1,71 @@
//! Events emitted from the Core Engine to Bevy
use crate::networking::{NodeId, SessionId, VectorClock};
use bevy::prelude::*;
use uuid::Uuid;
/// Events that the Core Engine emits to Bevy
#[derive(Debug, Clone)]
pub enum EngineEvent {
// Networking status
NetworkingStarted {
session_id: SessionId,
node_id: NodeId,
},
NetworkingFailed {
error: String,
},
NetworkingStopped,
SessionJoined {
session_id: SessionId,
},
SessionLeft,
// Peer events
PeerJoined {
node_id: NodeId,
},
PeerLeft {
node_id: NodeId,
},
// CRDT sync events
EntitySpawned {
entity_id: Uuid,
position: Vec3,
rotation: Quat,
version: VectorClock,
},
EntityUpdated {
entity_id: Uuid,
position: Vec3,
rotation: Quat,
version: VectorClock,
},
EntityDeleted {
entity_id: Uuid,
version: VectorClock,
},
// Lock events
LockAcquired {
entity_id: Uuid,
holder: NodeId,
},
LockReleased {
entity_id: Uuid,
},
LockDenied {
entity_id: Uuid,
current_holder: NodeId,
},
LockExpired {
entity_id: Uuid,
},
// Clock events
ClockTicked {
sequence: u64,
clock: VectorClock,
},
}

View File

@@ -0,0 +1,118 @@
//! Semantic game actions
//!
//! Actions represent what the player wants to do, independent of how they
//! triggered it. This enables input remapping and accessibility.
use glam::Vec2;
/// High-level game actions that result from input processing
#[derive(Debug, Clone, PartialEq)]
pub enum GameAction {
/// Move an entity in 2D (XY plane)
MoveEntity {
/// Movement delta (in screen/world space)
delta: Vec2,
},
/// Rotate an entity
RotateEntity {
/// Rotation delta (yaw, pitch)
delta: Vec2,
},
/// Move entity along Z axis (depth)
MoveEntityDepth {
/// Depth delta
delta: f32,
},
/// Select/deselect an entity at a position
SelectEntity {
/// Screen position
position: Vec2,
},
/// Begin dragging at a position
BeginDrag {
/// Screen position
position: Vec2,
},
/// Continue dragging
ContinueDrag {
/// Current screen position
position: Vec2,
/// Delta since last drag event
delta: Vec2,
},
/// End dragging
EndDrag {
/// Final screen position
position: Vec2,
},
/// Reset entity to default state
ResetEntity,
/// Delete selected entity
DeleteEntity,
/// Spawn new entity at position
SpawnEntity {
/// Screen position
position: Vec2,
},
/// Camera movement
MoveCamera {
/// Movement delta
delta: Vec2,
},
/// Camera zoom
ZoomCamera {
/// Zoom delta
delta: f32,
},
/// Toggle UI panel
ToggleUI,
/// Confirm action (Enter, Space, etc.)
Confirm,
/// Cancel action (Escape, etc.)
Cancel,
/// Undo last action
Undo,
/// Redo last undone action
Redo,
}
impl GameAction {
/// Get a human-readable description of this action
pub fn description(&self) -> &'static str {
match self {
GameAction::MoveEntity { .. } => "Move entity in XY plane",
GameAction::RotateEntity { .. } => "Rotate entity",
GameAction::MoveEntityDepth { .. } => "Move entity along Z axis",
GameAction::SelectEntity { .. } => "Select/deselect entity",
GameAction::BeginDrag { .. } => "Begin dragging",
GameAction::ContinueDrag { .. } => "Continue dragging",
GameAction::EndDrag { .. } => "End dragging",
GameAction::ResetEntity => "Reset entity to default",
GameAction::DeleteEntity => "Delete selected entity",
GameAction::SpawnEntity { .. } => "Spawn new entity",
GameAction::MoveCamera { .. } => "Move camera",
GameAction::ZoomCamera { .. } => "Zoom camera",
GameAction::ToggleUI => "Toggle UI panel",
GameAction::Confirm => "Confirm",
GameAction::Cancel => "Cancel",
GameAction::Undo => "Undo",
GameAction::Redo => "Redo",
}
}
}

View File

@@ -0,0 +1,337 @@
//! Input controller - maps raw InputEvents to semantic GameActions
//!
//! This layer provides:
//! - Input remapping (change key bindings)
//! - Accessibility (alternative input methods)
//! - Context-aware bindings (different actions in different modes)
use super::game_actions::GameAction;
use super::input_events::{InputEvent, KeyCode, MouseButton, TouchPhase};
use glam::Vec2;
use std::collections::HashMap;
/// Input binding - maps an input trigger to a game action
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum InputBinding {
/// Mouse button press/release
MouseButton(MouseButton),
/// Mouse drag with a specific button
MouseDrag(MouseButton),
/// Mouse wheel scroll
MouseWheel,
/// Keyboard key press
Key(KeyCode),
/// Keyboard key with modifiers
KeyWithModifiers {
key: KeyCode,
shift: bool,
ctrl: bool,
alt: bool,
meta: bool,
},
/// Stylus input (Apple Pencil, etc.)
StylusDrag,
/// Touch input
TouchDrag,
}
/// Input context - different binding sets for different game modes
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum InputContext {
/// Manipulating 3D entities
EntityManipulation,
/// Camera control
CameraControl,
/// UI interaction
UI,
/// Text input
TextInput,
}
/// Accessibility settings for input processing
#[derive(Debug, Clone)]
pub struct AccessibilitySettings {
/// Mouse sensitivity multiplier (1.0 = normal)
pub mouse_sensitivity: f32,
/// Scroll sensitivity multiplier (1.0 = normal)
pub scroll_sensitivity: f32,
/// Stylus pressure sensitivity (1.0 = normal)
pub stylus_sensitivity: f32,
/// Enable one-handed mode (use keyboard for rotation)
pub one_handed_mode: bool,
/// Invert Y axis for rotation
pub invert_y: bool,
/// Minimum drag distance before registering as drag (in pixels)
pub drag_threshold: f32,
}
impl Default for AccessibilitySettings {
fn default() -> Self {
Self {
mouse_sensitivity: 1.0,
scroll_sensitivity: 1.0,
stylus_sensitivity: 1.0,
one_handed_mode: false,
invert_y: false,
drag_threshold: 2.0,
}
}
}
/// Input controller - converts InputEvents to GameActions
pub struct InputController {
/// Current input context
current_context: InputContext,
/// Bindings for each context
bindings: HashMap<InputContext, HashMap<InputBinding, GameAction>>,
/// Accessibility settings
accessibility: AccessibilitySettings,
/// Drag state tracking
drag_state: DragState,
}
#[derive(Default)]
struct DragState {
/// Is currently dragging
active: bool,
/// Which button/input is dragging
source: Option<DragSource>,
/// Start position
start_pos: Vec2,
/// Last position
last_pos: Vec2,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DragSource {
MouseLeft,
MouseRight,
Stylus,
Touch,
}
impl InputController {
/// Create a new input controller with default bindings
pub fn new() -> Self {
let mut controller = Self {
current_context: InputContext::EntityManipulation,
bindings: HashMap::new(),
accessibility: AccessibilitySettings::default(),
drag_state: DragState::default(),
};
controller.setup_default_bindings();
controller
}
/// Set the current input context
pub fn set_context(&mut self, context: InputContext) {
self.current_context = context;
}
/// Get the current context
pub fn context(&self) -> InputContext {
self.current_context
}
/// Update accessibility settings
pub fn set_accessibility(&mut self, settings: AccessibilitySettings) {
self.accessibility = settings;
}
/// Get current accessibility settings
pub fn accessibility(&self) -> &AccessibilitySettings {
&self.accessibility
}
/// Process an input event and produce game actions
pub fn process_event(&mut self, event: &InputEvent) -> Vec<GameAction> {
let mut actions = Vec::new();
match event {
InputEvent::Mouse { pos, button, phase } => {
self.process_mouse(*pos, *button, *phase, &mut actions);
}
InputEvent::MouseWheel { delta, pos: _ } => {
let adjusted_delta = delta.y * self.accessibility.scroll_sensitivity;
actions.push(GameAction::MoveEntityDepth { delta: adjusted_delta });
}
InputEvent::Keyboard { key, pressed, modifiers: _ } => {
if *pressed {
self.process_key(*key, &mut actions);
}
}
InputEvent::Stylus { pos, pressure: _, tilt: _, phase, timestamp: _ } => {
self.process_stylus(*pos, *phase, &mut actions);
}
InputEvent::Touch { pos, phase, id: _ } => {
self.process_touch(*pos, *phase, &mut actions);
}
}
actions
}
/// Process mouse input
fn process_mouse(&mut self, pos: Vec2, button: MouseButton, phase: TouchPhase, actions: &mut Vec<GameAction>) {
match phase {
TouchPhase::Started => {
// Single click = select
actions.push(GameAction::SelectEntity { position: pos });
// Start drag tracking
self.drag_state.active = true;
self.drag_state.source = Some(match button {
MouseButton::Left => DragSource::MouseLeft,
MouseButton::Right => DragSource::MouseRight,
MouseButton::Middle => return, // Don't handle middle button
});
self.drag_state.start_pos = pos;
self.drag_state.last_pos = pos;
actions.push(GameAction::BeginDrag { position: pos });
}
TouchPhase::Moved => {
if self.drag_state.active {
let delta = (pos - self.drag_state.last_pos) * self.accessibility.mouse_sensitivity;
self.drag_state.last_pos = pos;
// Check if we've exceeded drag threshold
let total_delta = pos - self.drag_state.start_pos;
if total_delta.length() < self.accessibility.drag_threshold {
return; // Too small to count as drag
}
actions.push(GameAction::ContinueDrag { position: pos, delta });
// Context-specific drag actions
match self.current_context {
InputContext::EntityManipulation => {
match self.drag_state.source {
Some(DragSource::MouseLeft) => {
actions.push(GameAction::MoveEntity { delta });
}
Some(DragSource::MouseRight) => {
let adjusted_delta = if self.accessibility.invert_y {
Vec2::new(delta.x, -delta.y)
} else {
delta
};
actions.push(GameAction::RotateEntity { delta: adjusted_delta });
}
_ => {}
}
}
InputContext::CameraControl => {
actions.push(GameAction::MoveCamera { delta });
}
_ => {}
}
}
}
TouchPhase::Ended | TouchPhase::Cancelled => {
if self.drag_state.active {
actions.push(GameAction::EndDrag { position: pos });
self.drag_state.active = false;
self.drag_state.source = None;
}
}
}
}
/// Process keyboard input
fn process_key(&mut self, key: KeyCode, actions: &mut Vec<GameAction>) {
match key {
KeyCode::KeyR => actions.push(GameAction::ResetEntity),
KeyCode::Delete | KeyCode::Backspace => actions.push(GameAction::DeleteEntity),
KeyCode::KeyZ if self.accessibility.one_handed_mode => {
// In one-handed mode, Z key can trigger actions
actions.push(GameAction::Undo);
}
KeyCode::Escape => actions.push(GameAction::Cancel),
KeyCode::Enter => actions.push(GameAction::Confirm),
KeyCode::Tab => actions.push(GameAction::ToggleUI),
_ => {}
}
}
/// Process stylus input (Apple Pencil, etc.)
fn process_stylus(&mut self, pos: Vec2, phase: TouchPhase, actions: &mut Vec<GameAction>) {
match phase {
TouchPhase::Started => {
actions.push(GameAction::SelectEntity { position: pos });
actions.push(GameAction::BeginDrag { position: pos });
self.drag_state.active = true;
self.drag_state.source = Some(DragSource::Stylus);
self.drag_state.start_pos = pos;
self.drag_state.last_pos = pos;
}
TouchPhase::Moved => {
if self.drag_state.active {
let delta = (pos - self.drag_state.last_pos) * self.accessibility.stylus_sensitivity;
self.drag_state.last_pos = pos;
actions.push(GameAction::ContinueDrag { position: pos, delta });
actions.push(GameAction::MoveEntity { delta });
}
}
TouchPhase::Ended | TouchPhase::Cancelled => {
if self.drag_state.active {
actions.push(GameAction::EndDrag { position: pos });
self.drag_state.active = false;
self.drag_state.source = None;
}
}
}
}
/// Process touch input
fn process_touch(&mut self, pos: Vec2, phase: TouchPhase, actions: &mut Vec<GameAction>) {
// For now, treat touch like stylus
self.process_stylus(pos, phase, actions);
}
/// Set up default input bindings
fn setup_default_bindings(&mut self) {
// For now, bindings are hardcoded in process_event
// Later, we can make this fully data-driven
}
}
impl Default for InputController {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[path = "input_controller_tests.rs"]
mod tests;

View File

@@ -0,0 +1,326 @@
//! Unit tests for InputController
use super::{AccessibilitySettings, InputContext, InputController};
use crate::engine::game_actions::GameAction;
use crate::engine::input_events::{InputEvent, KeyCode, MouseButton, TouchPhase};
use glam::Vec2;
#[test]
fn test_mouse_left_drag_produces_move_entity() {
let mut controller = InputController::new();
// Mouse down at (100, 100)
let actions = controller.process_event(&InputEvent::Mouse {
pos: Vec2::new(100.0, 100.0),
button: MouseButton::Left,
phase: TouchPhase::Started,
});
// Should select entity and begin drag
assert!(actions.iter().any(|a| matches!(a, GameAction::SelectEntity { .. })));
assert!(actions.iter().any(|a| matches!(a, GameAction::BeginDrag { .. })));
// Mouse drag to (150, 120)
let actions = controller.process_event(&InputEvent::Mouse {
pos: Vec2::new(150.0, 120.0),
button: MouseButton::Left,
phase: TouchPhase::Moved,
});
// Should produce MoveEntity with delta
let move_action = actions.iter().find_map(|a| {
if let GameAction::MoveEntity { delta } = a {
Some(delta)
} else {
None
}
});
assert!(move_action.is_some());
let delta = move_action.unwrap();
assert_eq!(*delta, Vec2::new(50.0, 20.0));
}
#[test]
fn test_mouse_right_drag_produces_rotate_entity() {
let mut controller = InputController::new();
// Right mouse down
controller.process_event(&InputEvent::Mouse {
pos: Vec2::new(100.0, 100.0),
button: MouseButton::Right,
phase: TouchPhase::Started,
});
// Right mouse drag
let actions = controller.process_event(&InputEvent::Mouse {
pos: Vec2::new(120.0, 130.0),
button: MouseButton::Right,
phase: TouchPhase::Moved,
});
// Should produce RotateEntity
assert!(actions.iter().any(|a| matches!(a, GameAction::RotateEntity { .. })));
}
#[test]
fn test_mouse_wheel_produces_depth_movement() {
let mut controller = InputController::new();
let actions = controller.process_event(&InputEvent::MouseWheel {
delta: Vec2::new(0.0, 10.0),
pos: Vec2::new(100.0, 100.0),
});
// Should produce MoveEntityDepth
let depth_action = actions.iter().find_map(|a| {
if let GameAction::MoveEntityDepth { delta } = a {
Some(*delta)
} else {
None
}
});
assert_eq!(depth_action, Some(10.0));
}
#[test]
fn test_keyboard_r_resets_entity() {
let mut controller = InputController::new();
let actions = controller.process_event(&InputEvent::Keyboard {
key: KeyCode::KeyR,
pressed: true,
modifiers: Default::default(),
});
assert!(actions.contains(&GameAction::ResetEntity));
}
#[test]
fn test_keyboard_delete_removes_entity() {
let mut controller = InputController::new();
let actions = controller.process_event(&InputEvent::Keyboard {
key: KeyCode::Delete,
pressed: true,
modifiers: Default::default(),
});
assert!(actions.contains(&GameAction::DeleteEntity));
}
#[test]
fn test_drag_threshold_prevents_tiny_movements() {
let mut controller = InputController::new();
controller.set_accessibility(AccessibilitySettings {
drag_threshold: 10.0,
..Default::default()
});
// Start drag
controller.process_event(&InputEvent::Mouse {
pos: Vec2::new(100.0, 100.0),
button: MouseButton::Left,
phase: TouchPhase::Started,
});
// Move only 2 pixels (below threshold of 10)
let actions = controller.process_event(&InputEvent::Mouse {
pos: Vec2::new(102.0, 100.0),
button: MouseButton::Left,
phase: TouchPhase::Moved,
});
// Should NOT produce MoveEntity (below threshold)
assert!(!actions.iter().any(|a| matches!(a, GameAction::MoveEntity { .. })));
// Move 15 pixels total (above threshold)
let actions = controller.process_event(&InputEvent::Mouse {
pos: Vec2::new(115.0, 100.0),
button: MouseButton::Left,
phase: TouchPhase::Moved,
});
// NOW should produce MoveEntity
assert!(actions.iter().any(|a| matches!(a, GameAction::MoveEntity { .. })));
}
#[test]
fn test_mouse_sensitivity_multiplier() {
let mut controller = InputController::new();
controller.set_accessibility(AccessibilitySettings {
mouse_sensitivity: 2.0,
..Default::default()
});
// Start drag
controller.process_event(&InputEvent::Mouse {
pos: Vec2::new(100.0, 100.0),
button: MouseButton::Left,
phase: TouchPhase::Started,
});
// Move 10 pixels
let actions = controller.process_event(&InputEvent::Mouse {
pos: Vec2::new(110.0, 100.0),
button: MouseButton::Left,
phase: TouchPhase::Moved,
});
// Delta should be doubled (10 * 2.0 = 20)
let delta = actions.iter().find_map(|a| {
if let GameAction::MoveEntity { delta } = a {
Some(*delta)
} else {
None
}
});
assert_eq!(delta, Some(Vec2::new(20.0, 0.0)));
}
#[test]
fn test_invert_y_axis() {
let mut controller = InputController::new();
controller.set_accessibility(AccessibilitySettings {
invert_y: true,
..Default::default()
});
// Start right-click drag
controller.process_event(&InputEvent::Mouse {
pos: Vec2::new(100.0, 100.0),
button: MouseButton::Right,
phase: TouchPhase::Started,
});
// Drag down (positive Y)
let actions = controller.process_event(&InputEvent::Mouse {
pos: Vec2::new(100.0, 110.0),
button: MouseButton::Right,
phase: TouchPhase::Moved,
});
// Y delta should be inverted
let delta = actions.iter().find_map(|a| {
if let GameAction::RotateEntity { delta } = a {
Some(*delta)
} else {
None
}
});
assert!(delta.is_some());
assert!(delta.unwrap().y < 0.0); // Should be negative (inverted)
}
#[test]
fn test_drag_sequence_produces_begin_continue_end() {
let mut controller = InputController::new();
// Started
let actions = controller.process_event(&InputEvent::Mouse {
pos: Vec2::new(100.0, 100.0),
button: MouseButton::Left,
phase: TouchPhase::Started,
});
assert!(actions.iter().any(|a| matches!(a, GameAction::BeginDrag { .. })));
// Moved
let actions = controller.process_event(&InputEvent::Mouse {
pos: Vec2::new(150.0, 100.0),
button: MouseButton::Left,
phase: TouchPhase::Moved,
});
assert!(actions.iter().any(|a| matches!(a, GameAction::ContinueDrag { .. })));
// Ended
let actions = controller.process_event(&InputEvent::Mouse {
pos: Vec2::new(150.0, 100.0),
button: MouseButton::Left,
phase: TouchPhase::Ended,
});
assert!(actions.iter().any(|a| matches!(a, GameAction::EndDrag { .. })));
}
#[test]
fn test_stylus_produces_move_entity() {
let mut controller = InputController::new();
// Stylus down
controller.process_event(&InputEvent::Stylus {
pos: Vec2::new(100.0, 100.0),
pressure: 0.5,
tilt: Vec2::ZERO,
phase: TouchPhase::Started,
timestamp: 0.0,
});
// Stylus drag
let actions = controller.process_event(&InputEvent::Stylus {
pos: Vec2::new(150.0, 120.0),
pressure: 0.8,
tilt: Vec2::ZERO,
phase: TouchPhase::Moved,
timestamp: 0.016,
});
// Should produce MoveEntity
assert!(actions.iter().any(|a| matches!(a, GameAction::MoveEntity { .. })));
}
#[test]
fn test_context_switching() {
let mut controller = InputController::new();
// Start in EntityManipulation context
assert_eq!(controller.context(), InputContext::EntityManipulation);
// Switch to CameraControl
controller.set_context(InputContext::CameraControl);
assert_eq!(controller.context(), InputContext::CameraControl);
// Start drag
controller.process_event(&InputEvent::Mouse {
pos: Vec2::new(100.0, 100.0),
button: MouseButton::Left,
phase: TouchPhase::Started,
});
// Drag in CameraControl context
let actions = controller.process_event(&InputEvent::Mouse {
pos: Vec2::new(150.0, 100.0),
button: MouseButton::Left,
phase: TouchPhase::Moved,
});
// Should produce MoveCamera instead of MoveEntity
assert!(actions.iter().any(|a| matches!(a, GameAction::MoveCamera { .. })));
assert!(!actions.iter().any(|a| matches!(a, GameAction::MoveEntity { .. })));
}
#[test]
fn test_scroll_sensitivity() {
let mut controller = InputController::new();
controller.set_accessibility(AccessibilitySettings {
scroll_sensitivity: 3.0,
..Default::default()
});
let actions = controller.process_event(&InputEvent::MouseWheel {
delta: Vec2::new(0.0, 5.0),
pos: Vec2::ZERO,
});
// Delta should be tripled (5.0 * 3.0 = 15.0)
let depth_delta = actions.iter().find_map(|a| {
if let GameAction::MoveEntityDepth { delta } = a {
Some(*delta)
} else {
None
}
});
assert_eq!(depth_delta, Some(15.0));
}

View File

@@ -0,0 +1,133 @@
//! Abstract input event types for the engine
//!
//! These types are platform-agnostic and represent all forms of input
//! (stylus, mouse, touch) in a unified way. Platform-specific code
//! (iOS pencil bridge, desktop mouse) converts to these types.
use glam::Vec2;
/// Phase of a touch/stylus/mouse input
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TouchPhase {
/// Input just started
Started,
/// Input moved
Moved,
/// Input ended normally
Ended,
/// Input was cancelled (e.g., system gesture)
Cancelled,
}
/// Mouse button types
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MouseButton {
Left,
Right,
Middle,
}
/// Keyboard key (using winit's KeyCode for now - can abstract later)
pub use winit::keyboard::KeyCode;
/// Keyboard modifiers
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Modifiers {
pub shift: bool,
pub ctrl: bool,
pub alt: bool,
pub meta: bool, // Command on macOS, Windows key on Windows
}
/// Abstract input event that the engine processes
///
/// Platform-specific code converts native input (UITouch, winit events)
/// into these engine-agnostic events.
#[derive(Debug, Clone, Copy)]
pub enum InputEvent {
/// Stylus input (Apple Pencil, Surface Pen, etc.)
Stylus {
/// Screen position in pixels
pos: Vec2,
/// Pressure (0.0 = no pressure, 1.0+ = max pressure)
/// Note: Apple Pencil reports 0.0-4.0 range
pressure: f32,
/// Tilt vector:
/// - x: altitude angle (0 = flat on screen, π/2 = perpendicular)
/// - y: azimuth angle (rotation around vertical axis)
tilt: Vec2,
/// Touch phase
phase: TouchPhase,
/// Platform timestamp (for input prediction)
timestamp: f64,
},
/// Mouse input (desktop)
Mouse {
/// Screen position in pixels
pos: Vec2,
/// Which button
button: MouseButton,
/// Touch phase
phase: TouchPhase,
},
/// Touch input (fingers on touchscreen)
Touch {
/// Screen position in pixels
pos: Vec2,
/// Touch phase
phase: TouchPhase,
/// Touch ID (for multi-touch tracking)
id: u64,
},
/// Keyboard input
Keyboard {
/// Physical key code
key: KeyCode,
/// Whether the key was pressed or released
pressed: bool,
/// Modifier keys held during the event
modifiers: Modifiers,
},
/// Mouse wheel scroll
MouseWheel {
/// Scroll delta (pixels or lines depending on device)
delta: Vec2,
/// Current mouse position
pos: Vec2,
},
}
impl InputEvent {
/// Get the position for positional input types
pub fn position(&self) -> Option<Vec2> {
match self {
InputEvent::Stylus { pos, .. } => Some(*pos),
InputEvent::Mouse { pos, .. } => Some(*pos),
InputEvent::Touch { pos, .. } => Some(*pos),
InputEvent::MouseWheel { pos, .. } => Some(*pos),
InputEvent::Keyboard { .. } => None,
}
}
/// Get the phase for input types that have phases
pub fn phase(&self) -> Option<TouchPhase> {
match self {
InputEvent::Stylus { phase, .. } => Some(*phase),
InputEvent::Mouse { phase, .. } => Some(*phase),
InputEvent::Touch { phase, .. } => Some(*phase),
InputEvent::Keyboard { .. } | InputEvent::MouseWheel { .. } => None,
}
}
/// Check if this is an active input (not ended/cancelled)
pub fn is_active(&self) -> bool {
match self.phase() {
Some(phase) => !matches!(phase, TouchPhase::Ended | TouchPhase::Cancelled),
None => true, // Keyboard and wheel events are considered instantaneous
}
}
}

View File

@@ -0,0 +1,21 @@
//! Core Engine module - networking and persistence outside Bevy
mod bridge;
mod commands;
mod core;
mod events;
mod game_actions;
mod input_controller;
mod input_events;
mod networking;
mod persistence;
pub use bridge::{EngineBridge, EngineHandle};
pub use commands::EngineCommand;
pub use core::EngineCore;
pub use events::EngineEvent;
pub use game_actions::GameAction;
pub use input_controller::{AccessibilitySettings, InputContext, InputController};
pub use input_events::{InputEvent, KeyCode, Modifiers, MouseButton, TouchPhase};
pub use networking::NetworkingManager;
pub use persistence::PersistenceManager;

View File

@@ -0,0 +1,243 @@
//! Networking Manager - handles iroh networking and CRDT state outside Bevy
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time;
use bytes::Bytes;
use futures_lite::StreamExt;
use crate::networking::{
EntityLockRegistry, NodeId, OperationLog, SessionId, TombstoneRegistry, VectorClock,
VersionedMessage, SyncMessage, LockMessage,
};
use super::EngineEvent;
pub struct NetworkingManager {
session_id: SessionId,
node_id: NodeId,
// Iroh networking
sender: iroh_gossip::api::GossipSender,
receiver: iroh_gossip::api::GossipReceiver,
// Keep these alive for the lifetime of the manager
_endpoint: iroh::Endpoint,
_router: iroh::protocol::Router,
_gossip: iroh_gossip::net::Gossip,
// CRDT state
vector_clock: VectorClock,
operation_log: OperationLog,
tombstones: TombstoneRegistry,
locks: EntityLockRegistry,
// Track locks we own for heartbeat broadcasting
our_locks: std::collections::HashSet<uuid::Uuid>,
}
impl NetworkingManager {
pub async fn new(session_id: SessionId) -> anyhow::Result<Self> {
use iroh::{
discovery::mdns::MdnsDiscovery,
protocol::Router,
Endpoint,
};
use iroh_gossip::{
net::Gossip,
proto::TopicId,
};
// Create iroh endpoint with mDNS discovery
let endpoint = Endpoint::builder()
.discovery(MdnsDiscovery::builder())
.bind()
.await?;
let endpoint_id = endpoint.addr().id;
// Convert endpoint ID to NodeId (using first 16 bytes)
let id_bytes = endpoint_id.as_bytes();
let mut node_id_bytes = [0u8; 16];
node_id_bytes.copy_from_slice(&id_bytes[..16]);
let node_id = NodeId::from_bytes(node_id_bytes);
// Create gossip protocol
let gossip = Gossip::builder().spawn(endpoint.clone());
// Derive session-specific ALPN for network isolation
let session_alpn = session_id.to_alpn();
// Set up router to accept session ALPN
let router = Router::builder(endpoint.clone())
.accept(session_alpn.as_slice(), gossip.clone())
.spawn();
// Subscribe to topic derived from session ALPN
let topic_id = TopicId::from_bytes(session_alpn);
let subscribe_handle = gossip.subscribe(topic_id, vec![]).await?;
let (sender, receiver) = subscribe_handle.split();
tracing::info!(
"NetworkingManager started for session {} with node {}",
session_id.to_code(),
node_id
);
let manager = Self {
session_id,
node_id,
sender,
receiver,
_endpoint: endpoint,
_router: router,
_gossip: gossip,
vector_clock: VectorClock::new(),
operation_log: OperationLog::new(),
tombstones: TombstoneRegistry::new(),
locks: EntityLockRegistry::new(),
our_locks: std::collections::HashSet::new(),
};
Ok(manager)
}
pub fn node_id(&self) -> NodeId {
self.node_id
}
pub fn session_id(&self) -> SessionId {
self.session_id.clone()
}
/// Process gossip events (unbounded) and periodic tasks (heartbeats, lock cleanup)
pub async fn run(mut self, event_tx: mpsc::UnboundedSender<EngineEvent>) {
let mut heartbeat_interval = time::interval(Duration::from_secs(1));
loop {
tokio::select! {
// Process gossip events unbounded (as fast as they arrive)
Some(result) = self.receiver.next() => {
match result {
Ok(event) => {
use iroh_gossip::api::Event;
if let Event::Received(msg) = event {
self.handle_sync_message(&msg.content, &event_tx).await;
}
// Note: Neighbor events are not exposed in the current API
}
Err(e) => {
tracing::warn!("Gossip receiver error: {}", e);
}
}
}
// Periodic tasks: heartbeats and lock cleanup
_ = heartbeat_interval.tick() => {
self.broadcast_lock_heartbeats(&event_tx).await;
self.cleanup_expired_locks(&event_tx);
}
}
}
}
async fn handle_sync_message(&mut self, msg_bytes: &[u8], event_tx: &mpsc::UnboundedSender<EngineEvent>) {
// Deserialize SyncMessage
let versioned: VersionedMessage = match bincode::deserialize(msg_bytes) {
Ok(v) => v,
Err(e) => {
tracing::warn!("Failed to deserialize sync message: {}", e);
return;
}
};
match versioned.message {
SyncMessage::Lock(lock_msg) => {
self.handle_lock_message(lock_msg, event_tx);
}
_ => {
// TODO: Handle other message types (ComponentOp, EntitySpawn, etc.)
tracing::debug!("Unhandled sync message type");
}
}
}
fn handle_lock_message(&mut self, msg: LockMessage, event_tx: &mpsc::UnboundedSender<EngineEvent>) {
match msg {
LockMessage::LockRequest { entity_id, node_id } => {
match self.locks.try_acquire(entity_id, node_id) {
Ok(()) => {
// Track if this is our lock
if node_id == self.node_id {
self.our_locks.insert(entity_id);
}
let _ = event_tx.send(EngineEvent::LockAcquired {
entity_id,
holder: node_id,
});
}
Err(current_holder) => {
let _ = event_tx.send(EngineEvent::LockDenied {
entity_id,
current_holder,
});
}
}
}
LockMessage::LockHeartbeat { entity_id, holder } => {
self.locks.renew_heartbeat(entity_id, holder);
}
LockMessage::LockRelease { entity_id, node_id } => {
self.locks.release(entity_id, node_id);
// Remove from our locks tracking
if node_id == self.node_id {
self.our_locks.remove(&entity_id);
}
let _ = event_tx.send(EngineEvent::LockReleased { entity_id });
}
_ => {}
}
}
async fn broadcast_lock_heartbeats(&mut self, _event_tx: &mpsc::UnboundedSender<EngineEvent>) {
// Broadcast heartbeats for locks we hold
for entity_id in self.our_locks.iter().copied() {
self.locks.renew_heartbeat(entity_id, self.node_id);
let msg = VersionedMessage::new(SyncMessage::Lock(LockMessage::LockHeartbeat {
entity_id,
holder: self.node_id,
}));
if let Ok(bytes) = bincode::serialize(&msg) {
let _ = self.sender.broadcast(Bytes::from(bytes)).await;
}
}
}
fn cleanup_expired_locks(&mut self, event_tx: &mpsc::UnboundedSender<EngineEvent>) {
// Get expired locks from registry
let expired = self.locks.get_expired_locks();
for entity_id in expired {
// Only cleanup if it's not our lock
if let Some(holder) = self.locks.get_holder(entity_id, self.node_id) {
if holder != self.node_id {
self.locks.force_release(entity_id);
let _ = event_tx.send(EngineEvent::LockExpired { entity_id });
tracing::info!("Lock expired for entity {}", entity_id);
}
}
}
}
pub async fn shutdown(self) {
tracing::info!("NetworkingManager shut down");
// endpoint and gossip will be dropped automatically
}
}

View File

@@ -0,0 +1,79 @@
//! Persistence Manager - handles SQLite storage outside Bevy
use rusqlite::{Connection, OptionalExtension};
use std::sync::{Arc, Mutex};
use crate::networking::{Session, SessionId};
pub struct PersistenceManager {
conn: Arc<Mutex<Connection>>,
}
impl PersistenceManager {
pub fn new(db_path: &str) -> Self {
let conn = Connection::open(db_path).expect("Failed to open database");
// Initialize schema (Phase 1 stub - will load from file in Phase 4)
let schema = "
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
state TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_active_at INTEGER NOT NULL
);
";
if let Err(e) = conn.execute_batch(schema) {
tracing::warn!("Failed to initialize schema: {}", e);
}
Self {
conn: Arc::new(Mutex::new(conn)),
}
}
pub fn save_session(&self, session: &Session) -> anyhow::Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT OR REPLACE INTO sessions (id, state, created_at, last_active_at)
VALUES (?1, ?2, ?3, ?4)",
(
session.id.to_code(),
format!("{:?}", session.state),
session.created_at,
session.last_active,
),
)?;
Ok(())
}
pub fn load_last_active_session(&self) -> anyhow::Result<Option<Session>> {
let conn = self.conn.lock().unwrap();
// Query for the most recently active session
let mut stmt = conn.prepare(
"SELECT id, state, created_at, last_active_at
FROM sessions
ORDER BY last_active_at DESC
LIMIT 1"
)?;
let session = stmt.query_row([], |row| {
let id_code: String = row.get(0)?;
let _state: String = row.get(1)?;
let _created_at: String = row.get(2)?;
let _last_active_at: String = row.get(3)?;
// Parse session ID from code
if let Ok(session_id) = SessionId::from_code(&id_code) {
Ok(Some(Session::new(session_id)))
} else {
Ok(None)
}
}).optional()?;
Ok(session.flatten())
}
}

View File

@@ -11,21 +11,23 @@
//! # Example
//!
//! ```no_run
//! use lib::ChatDb;
//! use libmarathon::ChatDb;
//!
//! let db = ChatDb::open("chat.db")?;
//!
//! // Get all messages from January 2024 to now
//! let messages = db.get_our_messages(None, None)?;
//! println!("Found {} messages", messages.len());
//! # Ok::<(), lib::ChatDbError>(())
//! # Ok::<(), libmarathon::ChatDbError>(())
//! ```
mod db;
mod error;
mod models;
pub mod engine;
pub mod networking;
pub mod persistence;
pub mod platform;
pub mod sync;
pub use db::ChatDb;

View File

@@ -446,7 +446,7 @@ fn apply_set_operation(
///
/// ```no_run
/// use bevy::prelude::*;
/// use lib::networking::receive_and_apply_deltas_system;
/// use libmarathon::networking::receive_and_apply_deltas_system;
///
/// App::new().add_systems(Update, receive_and_apply_deltas_system);
/// ```

View File

@@ -26,7 +26,7 @@ use crate::networking::error::{
///
/// # Examples
/// ```
/// use lib::networking::auth::validate_session_secret;
/// use libmarathon::networking::auth::validate_session_secret;
///
/// let secret = b"my_secret_key";
/// assert!(validate_session_secret(secret, secret).is_ok());

View File

@@ -56,7 +56,7 @@ impl BlobStore {
/// # Example
///
/// ```
/// use lib::networking::BlobStore;
/// use libmarathon::networking::BlobStore;
///
/// let store = BlobStore::new();
/// let data = vec![1, 2, 3, 4, 5];
@@ -157,7 +157,7 @@ impl Default for BlobStore {
/// # Example
///
/// ```
/// use lib::networking::should_use_blob;
/// use libmarathon::networking::should_use_blob;
///
/// let small_data = vec![1, 2, 3];
/// assert!(!should_use_blob(&small_data));
@@ -177,7 +177,7 @@ pub fn should_use_blob(data: &[u8]) -> bool {
/// # Example
///
/// ```
/// use lib::networking::{
/// use libmarathon::networking::{
/// BlobStore,
/// create_component_data,
/// };
@@ -209,7 +209,7 @@ pub fn create_component_data(data: Vec<u8>, blob_store: &BlobStore) -> Result<Co
/// # Example
///
/// ```
/// use lib::networking::{
/// use libmarathon::networking::{
/// BlobStore,
/// ComponentData,
/// get_component_data,

View File

@@ -21,7 +21,7 @@ use crate::networking::{
///
/// ```no_run
/// use bevy::prelude::*;
/// use lib::networking::auto_detect_transform_changes_system;
/// use libmarathon::networking::auto_detect_transform_changes_system;
///
/// App::new().add_systems(Update, auto_detect_transform_changes_system);
/// ```

View File

@@ -36,7 +36,7 @@ use crate::networking::vector_clock::NodeId;
///
/// ```
/// use bevy::prelude::*;
/// use lib::networking::NetworkedEntity;
/// use libmarathon::networking::NetworkedEntity;
/// use uuid::Uuid;
///
/// fn spawn_networked_entity(mut commands: Commands) {
@@ -72,7 +72,7 @@ impl NetworkedEntity {
/// # Example
///
/// ```
/// use lib::networking::NetworkedEntity;
/// use libmarathon::networking::NetworkedEntity;
/// use uuid::Uuid;
///
/// let node_id = Uuid::new_v4();
@@ -95,7 +95,7 @@ impl NetworkedEntity {
/// # Example
///
/// ```
/// use lib::networking::NetworkedEntity;
/// use libmarathon::networking::NetworkedEntity;
/// use uuid::Uuid;
///
/// let network_id = Uuid::new_v4();
@@ -136,7 +136,7 @@ impl Default for NetworkedEntity {
///
/// ```
/// use bevy::prelude::*;
/// use lib::networking::{
/// use libmarathon::networking::{
/// NetworkedEntity,
/// NetworkedTransform,
/// };
@@ -171,7 +171,7 @@ pub struct NetworkedTransform;
///
/// ```
/// use bevy::prelude::*;
/// use lib::networking::{
/// use libmarathon::networking::{
/// NetworkedEntity,
/// NetworkedSelection,
/// };
@@ -253,7 +253,7 @@ impl NetworkedSelection {
///
/// ```
/// use bevy::prelude::*;
/// use lib::networking::{
/// use libmarathon::networking::{
/// NetworkedDrawingPath,
/// NetworkedEntity,
/// };

View File

@@ -61,7 +61,7 @@ impl NodeVectorClock {
///
/// ```no_run
/// use bevy::prelude::*;
/// use lib::networking::generate_delta_system;
/// use libmarathon::networking::generate_delta_system;
///
/// App::new().add_systems(Update, generate_delta_system);
/// ```

View File

@@ -22,7 +22,7 @@ use bevy::prelude::*;
///
/// ```
/// use bevy::prelude::*;
/// use lib::networking::{
/// use libmarathon::networking::{
/// NetworkEntityMap,
/// NetworkedEntity,
/// };
@@ -68,7 +68,7 @@ impl NetworkEntityMap {
///
/// ```
/// use bevy::prelude::*;
/// use lib::networking::NetworkEntityMap;
/// use libmarathon::networking::NetworkEntityMap;
/// use uuid::Uuid;
///
/// # let mut world = World::new();
@@ -102,7 +102,7 @@ impl NetworkEntityMap {
///
/// ```
/// use bevy::prelude::*;
/// use lib::networking::NetworkEntityMap;
/// use libmarathon::networking::NetworkEntityMap;
/// use uuid::Uuid;
///
/// # let mut world = World::new();
@@ -128,7 +128,7 @@ impl NetworkEntityMap {
///
/// ```
/// use bevy::prelude::*;
/// use lib::networking::NetworkEntityMap;
/// use libmarathon::networking::NetworkEntityMap;
/// use uuid::Uuid;
///
/// # let mut world = World::new();
@@ -154,7 +154,7 @@ impl NetworkEntityMap {
///
/// ```
/// use bevy::prelude::*;
/// use lib::networking::NetworkEntityMap;
/// use libmarathon::networking::NetworkEntityMap;
/// use uuid::Uuid;
///
/// # let mut world = World::new();
@@ -183,7 +183,7 @@ impl NetworkEntityMap {
///
/// ```
/// use bevy::prelude::*;
/// use lib::networking::NetworkEntityMap;
/// use libmarathon::networking::NetworkEntityMap;
/// use uuid::Uuid;
///
/// # let mut world = World::new();
@@ -254,7 +254,7 @@ impl NetworkEntityMap {
/// Add this to your app:
/// ```no_run
/// use bevy::prelude::*;
/// use lib::networking::register_networked_entities_system;
/// use libmarathon::networking::register_networked_entities_system;
///
/// App::new().add_systems(PostUpdate, register_networked_entities_system);
/// ```
@@ -278,7 +278,7 @@ pub fn register_networked_entities_system(
/// Add this to your app:
/// ```no_run
/// use bevy::prelude::*;
/// use lib::networking::cleanup_despawned_entities_system;
/// use libmarathon::networking::cleanup_despawned_entities_system;
///
/// App::new().add_systems(PostUpdate, cleanup_despawned_entities_system);
/// ```

View File

@@ -44,7 +44,7 @@ use crate::networking::{
/// # Example
///
/// ```
/// use lib::networking::{build_join_request, SessionId, JoinType};
/// use libmarathon::networking::{build_join_request, SessionId, JoinType};
/// use uuid::Uuid;
///
/// let node_id = Uuid::new_v4();
@@ -329,7 +329,7 @@ pub fn apply_full_state(
///
/// ```no_run
/// use bevy::prelude::*;
/// use lib::networking::handle_join_requests_system;
/// use libmarathon::networking::handle_join_requests_system;
///
/// App::new().add_systems(Update, handle_join_requests_system);
/// ```

View File

@@ -16,7 +16,7 @@
//!
//! ```no_run
//! use bevy::prelude::*;
//! use lib::networking::{EntityLockRegistry, acquire_entity_lock, release_entity_lock};
//! use libmarathon::networking::{EntityLockRegistry, acquire_entity_lock, release_entity_lock};
//! use uuid::Uuid;
//!
//! fn my_system(world: &mut World) {
@@ -233,22 +233,47 @@ impl EntityLockRegistry {
}
}
/// Check if an entity is locked
pub fn is_locked(&self, entity_id: Uuid) -> bool {
self.locks.get(&entity_id).map_or(false, |lock| !lock.is_expired())
/// Check if an entity is locked by any node
///
/// Takes the local node ID to properly handle expiration:
/// - Our own locks are never considered expired (held exactly as long as selected)
/// - Remote locks are subject to the 5-second timeout
pub fn is_locked(&self, entity_id: Uuid, local_node_id: NodeId) -> bool {
self.locks.get(&entity_id).map_or(false, |lock| {
// Our own locks never expire
lock.holder == local_node_id || !lock.is_expired()
})
}
/// Check if an entity is locked by a specific node
pub fn is_locked_by(&self, entity_id: Uuid, node_id: NodeId) -> bool {
self.locks
.get(&entity_id)
.map_or(false, |lock| !lock.is_expired() && lock.holder == node_id)
///
/// Takes the local node ID to properly handle expiration:
/// - If checking our own lock, ignore expiration (held exactly as long as selected)
/// - If checking another node's lock, apply 5-second timeout
pub fn is_locked_by(&self, entity_id: Uuid, node_id: NodeId, local_node_id: NodeId) -> bool {
self.locks.get(&entity_id).map_or(false, |lock| {
if lock.holder != node_id {
// Not held by the queried node
false
} else if lock.holder == local_node_id {
// Checking our own lock - never expires
true
} else {
// Checking remote lock - check expiration
!lock.is_expired()
}
})
}
/// Get the holder of a lock (if locked)
pub fn get_holder(&self, entity_id: Uuid) -> Option<NodeId> {
/// Get the holder of a lock (if locked and not expired)
///
/// Takes the local node ID to properly handle expiration:
/// - Our own locks are never considered expired
/// - Remote locks are subject to the 5-second timeout
pub fn get_holder(&self, entity_id: Uuid, local_node_id: NodeId) -> Option<NodeId> {
self.locks.get(&entity_id).and_then(|lock| {
if !lock.is_expired() {
// Our own locks never expire
if lock.holder == local_node_id || !lock.is_expired() {
Some(lock.holder)
} else {
None
@@ -320,7 +345,7 @@ impl EntityLockRegistry {
/// Add to your app as an Update system:
/// ```no_run
/// use bevy::prelude::*;
/// use lib::networking::release_locks_on_deselection_system;
/// use libmarathon::networking::release_locks_on_deselection_system;
///
/// App::new().add_systems(Update, release_locks_on_deselection_system);
/// ```
@@ -373,27 +398,44 @@ pub fn release_locks_on_deselection_system(
/// System to clean up expired locks (crash recovery)
///
/// This system periodically removes locks that have exceeded their timeout
/// duration (default 5 seconds). This provides crash recovery - if a node
/// crashes while holding a lock, it will eventually expire.
/// duration (default 5 seconds). This provides crash recovery - if a **remote**
/// node crashes while holding a lock, it will eventually expire.
///
/// **Important**: Only remote locks are cleaned up. Local locks (held by this node)
/// are never timed out - they're held exactly as long as entities are selected,
/// and only released via deselection.
///
/// Add to your app as an Update system:
/// ```no_run
/// use bevy::prelude::*;
/// use lib::networking::cleanup_expired_locks_system;
/// use libmarathon::networking::cleanup_expired_locks_system;
///
/// App::new().add_systems(Update, cleanup_expired_locks_system);
/// ```
pub fn cleanup_expired_locks_system(
mut registry: ResMut<EntityLockRegistry>,
node_clock: Res<NodeVectorClock>,
bridge: Option<Res<GossipBridge>>,
) {
let expired = registry.get_expired_locks();
let node_id = node_clock.node_id;
// Only clean up REMOTE locks (locks held by other nodes)
// Our own locks are managed by release_locks_on_deselection_system
let expired: Vec<Uuid> = registry
.locks
.iter()
.filter(|(_, lock)| {
// Only expire locks held by OTHER nodes
lock.is_expired() && lock.holder != node_id
})
.map(|(entity_id, _)| *entity_id)
.collect();
if !expired.is_empty() {
info!("Cleaning up {} expired locks", expired.len());
info!("Cleaning up {} expired remote locks", expired.len());
for entity_id in expired {
debug!("Force-releasing expired lock on entity {}", entity_id);
debug!("Force-releasing expired remote lock on entity {}", entity_id);
registry.force_release(entity_id);
// Broadcast LockReleased
@@ -404,7 +446,7 @@ pub fn cleanup_expired_locks_system(
if let Err(e) = bridge.send(msg) {
error!("Failed to broadcast LockReleased for expired lock: {}", e);
} else {
info!("Expired lock cleaned up: entity {}", entity_id);
info!("Expired remote lock cleaned up: entity {}", entity_id);
}
}
}
@@ -422,14 +464,14 @@ pub fn cleanup_expired_locks_system(
/// use bevy::prelude::*;
/// use bevy::time::common_conditions::on_timer;
/// use std::time::Duration;
/// use lib::networking::broadcast_lock_heartbeats_system;
/// use libmarathon::networking::broadcast_lock_heartbeats_system;
///
/// App::new().add_systems(Update,
/// broadcast_lock_heartbeats_system.run_if(on_timer(Duration::from_secs(1)))
/// );
/// ```
pub fn broadcast_lock_heartbeats_system(
registry: Res<EntityLockRegistry>,
mut registry: ResMut<EntityLockRegistry>,
node_clock: Res<NodeVectorClock>,
bridge: Option<Res<GossipBridge>>,
) {
@@ -449,7 +491,13 @@ pub fn broadcast_lock_heartbeats_system(
debug!("Broadcasting {} lock heartbeats", our_locks.len());
// Broadcast heartbeat for each lock
// Renew local locks and broadcast heartbeat for each lock
for entity_id in &our_locks {
// Renew the lock locally first (don't rely on network loopback)
registry.renew_heartbeat(*entity_id, node_id);
}
// Broadcast heartbeat messages to peers
if let Some(ref bridge) = bridge {
for entity_id in our_locks {
let msg = VersionedMessage::new(SyncMessage::Lock(LockMessage::LockHeartbeat {
@@ -481,9 +529,9 @@ mod tests {
// Should acquire successfully
assert!(registry.try_acquire(entity_id, node_id).is_ok());
assert!(registry.is_locked(entity_id));
assert!(registry.is_locked_by(entity_id, node_id));
assert_eq!(registry.get_holder(entity_id), Some(node_id));
assert!(registry.is_locked(entity_id, node_id));
assert!(registry.is_locked_by(entity_id, node_id, node_id));
assert_eq!(registry.get_holder(entity_id, node_id), Some(node_id));
}
#[test]
@@ -509,7 +557,7 @@ mod tests {
// Acquire and release
registry.try_acquire(entity_id, node_id).unwrap();
assert!(registry.release(entity_id, node_id));
assert!(!registry.is_locked(entity_id));
assert!(!registry.is_locked(entity_id, node_id));
}
#[test]
@@ -524,8 +572,8 @@ mod tests {
// Node 2 cannot release
assert!(!registry.release(entity_id, node2));
assert!(registry.is_locked(entity_id));
assert!(registry.is_locked_by(entity_id, node1));
assert!(registry.is_locked(entity_id, node2));
assert!(registry.is_locked_by(entity_id, node1, node2));
}
#[test]
@@ -556,7 +604,7 @@ mod tests {
registry.try_acquire(entity_id, node_id).unwrap();
registry.force_release(entity_id);
assert!(!registry.is_locked(entity_id));
assert!(!registry.is_locked(entity_id, node_id));
}
#[test]

View File

@@ -45,7 +45,7 @@ pub enum MergeDecision {
/// # Example
///
/// ```
/// use lib::networking::{
/// use libmarathon::networking::{
/// VectorClock,
/// compare_operations_lww,
/// };

View File

@@ -48,7 +48,7 @@ use crate::networking::{
///
/// ```no_run
/// use bevy::prelude::*;
/// use lib::networking::message_dispatcher_system;
/// use libmarathon::networking::message_dispatcher_system;
///
/// App::new().add_systems(Update, message_dispatcher_system);
/// ```

View File

@@ -13,7 +13,7 @@
//! # Example
//!
//! ```
//! use lib::networking::*;
//! use libmarathon::networking::*;
//! use uuid::Uuid;
//!
//! // Create a vector clock and track operations
@@ -101,7 +101,7 @@ pub use vector_clock::*;
/// # Example
/// ```no_run
/// use bevy::prelude::*;
/// use lib::networking::spawn_networked_entity;
/// use libmarathon::networking::spawn_networked_entity;
/// use uuid::Uuid;
///
/// fn my_system(world: &mut World) {

View File

@@ -168,7 +168,7 @@ pub fn build_entity_operations(
///
/// ```
/// use bevy::prelude::*;
/// use lib::networking::{
/// use libmarathon::networking::{
/// VectorClock,
/// build_transform_operation,
/// };

View File

@@ -89,7 +89,7 @@ impl OperationLog {
/// # Example
///
/// ```
/// use lib::networking::{
/// use libmarathon::networking::{
/// EntityDelta,
/// OperationLog,
/// VectorClock,
@@ -230,7 +230,7 @@ impl Default for OperationLog {
/// # Example
///
/// ```
/// use lib::networking::{
/// use libmarathon::networking::{
/// VectorClock,
/// build_sync_request,
/// };
@@ -263,7 +263,7 @@ pub fn build_missing_deltas(deltas: Vec<EntityDelta>) -> VersionedMessage {
///
/// ```no_run
/// use bevy::prelude::*;
/// use lib::networking::handle_sync_requests_system;
/// use libmarathon::networking::handle_sync_requests_system;
///
/// App::new().add_systems(Update, handle_sync_requests_system);
/// ```

View File

@@ -15,7 +15,7 @@
//! ## Example
//!
//! ```
//! use lib::networking::{
//! use libmarathon::networking::{
//! OrElement,
//! OrSet,
//! };
@@ -116,7 +116,7 @@ where
/// # Example
///
/// ```
/// use lib::networking::OrSet;
/// use libmarathon::networking::OrSet;
/// use uuid::Uuid;
///
/// let node = Uuid::new_v4();
@@ -143,7 +143,7 @@ where
/// # Example
///
/// ```
/// use lib::networking::OrSet;
/// use libmarathon::networking::OrSet;
/// use uuid::Uuid;
///
/// let node = Uuid::new_v4();
@@ -190,7 +190,7 @@ where
/// # Example
///
/// ```
/// use lib::networking::OrSet;
/// use libmarathon::networking::OrSet;
/// use uuid::Uuid;
///
/// let node = Uuid::new_v4();
@@ -234,7 +234,7 @@ where
/// # Example
///
/// ```
/// use lib::networking::OrSet;
/// use libmarathon::networking::OrSet;
/// use uuid::Uuid;
///
/// let node1 = Uuid::new_v4();

View File

@@ -8,7 +8,7 @@
//!
//! ```no_run
//! use bevy::prelude::*;
//! use lib::networking::{
//! use libmarathon::networking::{
//! NetworkingConfig,
//! NetworkingPlugin,
//! };
@@ -116,7 +116,7 @@ impl Default for NetworkingConfig {
///
/// ```no_run
/// use bevy::prelude::*;
/// use lib::networking::{
/// use libmarathon::networking::{
/// NetworkingPlugin,
/// SessionSecret,
/// };
@@ -192,7 +192,7 @@ impl SessionSecret {
///
/// ```no_run
/// use bevy::prelude::*;
/// use lib::networking::{
/// use libmarathon::networking::{
/// NetworkingConfig,
/// NetworkingPlugin,
/// };
@@ -293,8 +293,13 @@ impl Plugin for NetworkingPlugin {
)),
);
// Last schedule - save session state on shutdown
app.add_systems(Last, save_session_on_shutdown_system);
// Auto-save session state every 5 seconds
app.add_systems(
Last,
save_session_on_shutdown_system.run_if(bevy::time::common_conditions::on_timer(
std::time::Duration::from_secs(5),
)),
);
info!(
"NetworkingPlugin initialized for node {}",
@@ -315,7 +320,7 @@ impl Plugin for NetworkingPlugin {
///
/// ```no_run
/// use bevy::prelude::*;
/// use lib::networking::NetworkingAppExt;
/// use libmarathon::networking::NetworkingAppExt;
/// use uuid::Uuid;
///
/// App::new()

View File

@@ -13,7 +13,7 @@
//! ## Example
//!
//! ```
//! use lib::networking::Rga;
//! use libmarathon::networking::Rga;
//! use uuid::Uuid;
//!
//! let node1 = Uuid::new_v4();
@@ -115,7 +115,7 @@ where
/// # Example
///
/// ```
/// use lib::networking::Rga;
/// use libmarathon::networking::Rga;
/// use uuid::Uuid;
///
/// let node = Uuid::new_v4();
@@ -153,7 +153,7 @@ where
/// # Example
///
/// ```
/// use lib::networking::Rga;
/// use libmarathon::networking::Rga;
/// use uuid::Uuid;
///
/// let node = Uuid::new_v4();
@@ -227,7 +227,7 @@ where
/// # Example
///
/// ```
/// use lib::networking::Rga;
/// use libmarathon::networking::Rga;
/// use uuid::Uuid;
///
/// let node = Uuid::new_v4();
@@ -301,7 +301,7 @@ where
/// # Example
///
/// ```
/// use lib::networking::Rga;
/// use libmarathon::networking::Rga;
/// use uuid::Uuid;
///
/// let node1 = Uuid::new_v4();

View File

@@ -47,7 +47,7 @@ use crate::{
/// Add to your app as a Startup system AFTER setup_persistence:
/// ```no_run
/// use bevy::prelude::*;
/// use lib::networking::initialize_session_system;
/// use libmarathon::networking::initialize_session_system;
///
/// App::new()
/// .add_systems(Startup, initialize_session_system);
@@ -136,21 +136,24 @@ pub fn initialize_session_system(world: &mut World) {
world.insert_resource(current_session);
}
/// System to save session state on shutdown
/// System to auto-save session state periodically
///
/// This system should run during app shutdown to persist session state
/// for auto-rejoin on next startup.
/// This system periodically saves session state to persist it for auto-rejoin
/// on next startup. Typically run every 5 seconds.
///
/// Add to your app using the Last schedule:
/// Add to your app using the Last schedule with a timer:
/// ```no_run
/// use bevy::prelude::*;
/// use lib::networking::save_session_on_shutdown_system;
/// use bevy::time::common_conditions::on_timer;
/// use libmarathon::networking::save_session_on_shutdown_system;
/// use std::time::Duration;
///
/// App::new()
/// .add_systems(Last, save_session_on_shutdown_system);
/// .add_systems(Last, save_session_on_shutdown_system
/// .run_if(on_timer(Duration::from_secs(5))));
/// ```
pub fn save_session_on_shutdown_system(world: &mut World) {
info!("Saving session state on shutdown...");
debug!("Auto-saving session state...");
// Get current session
let current_session = match world.get_resource::<CurrentSession>() {

View File

@@ -48,7 +48,7 @@ pub enum ComponentMergeDecision {
/// # Example
/// ```
/// use bevy::prelude::*;
/// use lib::networking::{
/// use libmarathon::networking::{
/// ClockComparison,
/// ComponentMergeDecision,
/// SyncComponent,
@@ -105,7 +105,7 @@ pub trait SyncComponent: Component + Reflect + Sized {
/// # Example
/// ```
/// use bevy::prelude::*;
/// use lib::networking::Synced;
/// use libmarathon::networking::Synced;
/// use sync_macros::Synced as SyncedDerive;
///
/// #[derive(Component, Reflect, Clone, serde::Serialize, serde::Deserialize, SyncedDerive)]
@@ -138,7 +138,7 @@ pub struct Synced;
/// # Example
/// ```
/// use bevy::prelude::*;
/// use lib::networking::DiagnoseSync;
/// use libmarathon::networking::DiagnoseSync;
///
/// let mut world = World::new();
/// let entity = world.spawn_empty().id();

View File

@@ -203,7 +203,7 @@ impl TombstoneRegistry {
///
/// ```no_run
/// use bevy::prelude::*;
/// use lib::networking::ToDelete;
/// use libmarathon::networking::ToDelete;
///
/// fn delete_entity_system(mut commands: Commands, entity: Entity) {
/// commands.entity(entity).insert(ToDelete);

View File

@@ -35,7 +35,7 @@ pub type NodeId = uuid::Uuid;
/// # Example
///
/// ```
/// use lib::networking::VectorClock;
/// use libmarathon::networking::VectorClock;
/// use uuid::Uuid;
///
/// let node1 = Uuid::new_v4();
@@ -76,7 +76,7 @@ impl VectorClock {
/// # Example
///
/// ```
/// use lib::networking::VectorClock;
/// use libmarathon::networking::VectorClock;
/// use uuid::Uuid;
///
/// let node = Uuid::new_v4();
@@ -109,7 +109,7 @@ impl VectorClock {
/// # Example
///
/// ```
/// use lib::networking::VectorClock;
/// use libmarathon::networking::VectorClock;
/// use uuid::Uuid;
///
/// let node1 = Uuid::new_v4();
@@ -141,7 +141,7 @@ impl VectorClock {
/// # Example
///
/// ```
/// use lib::networking::VectorClock;
/// use libmarathon::networking::VectorClock;
/// use uuid::Uuid;
///
/// let node = Uuid::new_v4();
@@ -195,7 +195,7 @@ impl VectorClock {
/// # Example
///
/// ```
/// use lib::networking::VectorClock;
/// use libmarathon::networking::VectorClock;
/// use uuid::Uuid;
///
/// let node1 = Uuid::new_v4();

View File

@@ -183,7 +183,7 @@ pub fn load_config_from_str(toml: &str) -> Result<PersistenceConfig> {
///
/// # Examples
/// ```no_run
/// # use lib::persistence::*;
/// # use libmarathon::persistence::*;
/// # fn example() -> Result<()> {
/// let config = load_config_from_file("persistence.toml")?;
/// # Ok(())

View File

@@ -280,7 +280,7 @@ pub fn flush_to_sqlite(ops: &[PersistenceOp], conn: &mut Connection) -> Result<u
/// # Examples
/// ```no_run
/// # use rusqlite::Connection;
/// # use lib::persistence::*;
/// # use libmarathon::persistence::*;
/// # fn example() -> anyhow::Result<()> {
/// let mut conn = Connection::open("app.db")?;
/// let info = checkpoint_wal(&mut conn, CheckpointMode::Passive)?;

View File

@@ -13,7 +13,7 @@
//!
//! ```no_run
//! use bevy::prelude::*;
//! use lib::persistence::*;
//! use libmarathon::persistence::*;
//!
//! fn setup(mut commands: Commands) {
//! // Spawn an entity with the Persisted marker

View File

@@ -21,7 +21,7 @@ use crate::persistence::*;
///
/// ```no_run
/// use bevy::prelude::*;
/// use lib::persistence::PersistencePlugin;
/// use libmarathon::persistence::PersistencePlugin;
///
/// App::new()
/// .add_plugins(PersistencePlugin::new("app.db"))
@@ -250,7 +250,7 @@ fn collect_dirty_entities_bevy_system(world: &mut World) {
///
/// ```no_run
/// # use bevy::prelude::*;
/// # use lib::persistence::*;
/// # use libmarathon::persistence::*;
/// App::new()
/// .add_plugins(PersistencePlugin::new("app.db"))
/// .add_systems(Update, auto_track_transform_changes_system)

View File

@@ -37,7 +37,7 @@ use crate::persistence::error::{
///
/// ```no_run
/// # use bevy::prelude::*;
/// # use lib::persistence::*;
/// # use libmarathon::persistence::*;
/// fn update_position(mut query: Query<(&mut Transform, &mut Persisted)>) {
/// for (mut transform, mut persisted) in query.iter_mut() {
/// transform.translation.x += 1.0;
@@ -92,7 +92,7 @@ pub trait Persistable: Component + Reflect {
/// # Examples
/// ```no_run
/// # use bevy::prelude::*;
/// # use lib::persistence::*;
/// # use libmarathon::persistence::*;
/// # fn example(component: &Transform, registry: &AppTypeRegistry) -> anyhow::Result<()> {
/// let registry = registry.read();
/// let bytes = serialize_component(component.as_reflect(), &registry)?;
@@ -141,7 +141,7 @@ pub fn serialize_component_typed(
/// # Examples
/// ```no_run
/// # use bevy::prelude::*;
/// # use lib::persistence::*;
/// # use libmarathon::persistence::*;
/// # fn example(bytes: &[u8], registry: &AppTypeRegistry) -> anyhow::Result<()> {
/// let registry = registry.read();
/// let reflected = deserialize_component(bytes, &registry)?;
@@ -204,7 +204,7 @@ pub fn deserialize_component_typed(
/// # Examples
/// ```no_run
/// # use bevy::prelude::*;
/// # use lib::persistence::*;
/// # use libmarathon::persistence::*;
/// # fn example(entity: Entity, world: &World, registry: &AppTypeRegistry) -> Option<()> {
/// let registry = registry.read();
/// let bytes = serialize_component_from_entity(

View File

@@ -0,0 +1,90 @@
//! Desktop event loop - owns winit window and event handling
//!
//! This module creates and manages the main window and event loop.
//! It converts winit events to InputEvents and provides them to the engine.
use super::winit_bridge;
use winit::application::ApplicationHandler;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::{Window, WindowId};
/// Main event loop runner for desktop platforms
pub struct DesktopApp {
window: Option<Window>,
}
impl DesktopApp {
pub fn new() -> Self {
Self { window: None }
}
}
impl ApplicationHandler for DesktopApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_none() {
let window_attributes = Window::default_attributes()
.with_title("Marathon")
.with_inner_size(winit::dpi::LogicalSize::new(1280, 720));
match event_loop.create_window(window_attributes) {
Ok(window) => {
tracing::info!("Created winit window");
self.window = Some(window);
}
Err(e) => {
tracing::error!("Failed to create window: {}", e);
}
}
}
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: WindowId,
event: WindowEvent,
) {
// Forward all input events to the bridge first
winit_bridge::push_window_event(&event);
match event {
WindowEvent::CloseRequested => {
tracing::info!("Window close requested");
event_loop.exit();
}
WindowEvent::RedrawRequested => {
// Rendering happens via Bevy
if let Some(window) = &self.window {
window.request_redraw();
}
}
_ => {}
}
}
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
// Request redraw for next frame
if let Some(window) = &self.window {
window.request_redraw();
}
}
}
/// Run the desktop application with the provided game update function
///
/// This takes ownership of the main thread and runs the winit event loop.
/// The update_fn is called each frame to update game logic.
pub fn run(mut update_fn: impl FnMut() + 'static) -> Result<(), Box<dyn std::error::Error>> {
let event_loop = EventLoop::new()?;
event_loop.set_control_flow(ControlFlow::Poll); // Run as fast as possible
let mut app = DesktopApp::new();
// Run the event loop, calling update_fn each frame
event_loop.run_app(&mut app)?;
Ok(())
}

View File

@@ -0,0 +1,9 @@
//! Desktop platform integration
//!
//! Owns the winit event loop and converts winit events to InputEvents.
mod event_loop;
mod winit_bridge;
pub use event_loop::run;
pub use winit_bridge::{drain_as_input_events, push_window_event};

View File

@@ -0,0 +1,225 @@
//! Desktop winit event loop integration
//!
//! This module owns the winit event loop and window, converting winit events
//! to engine-agnostic InputEvents.
use crate::engine::{InputEvent, KeyCode, Modifiers, MouseButton, TouchPhase};
use glam::Vec2;
use std::sync::Mutex;
use winit::event::{ElementState, MouseButton as WinitMouseButton, MouseScrollDelta, WindowEvent};
use winit::keyboard::PhysicalKey;
/// Raw winit input events before conversion
#[derive(Clone, Debug)]
pub enum RawWinitEvent {
MouseButton {
button: MouseButton,
state: ElementState,
position: Vec2,
},
CursorMoved {
position: Vec2,
},
Keyboard {
key: KeyCode,
state: ElementState,
modifiers: Modifiers,
},
MouseWheel {
delta: Vec2,
position: Vec2,
},
}
/// Thread-safe buffer for winit events
///
/// The winit event loop pushes events here.
/// The engine drains them each frame.
static BUFFER: Mutex<Vec<RawWinitEvent>> = Mutex::new(Vec::new());
/// Current input state for tracking drags and modifiers
static INPUT_STATE: Mutex<InputState> = Mutex::new(InputState {
left_pressed: false,
right_pressed: false,
middle_pressed: false,
last_position: Vec2::ZERO,
modifiers: Modifiers {
shift: false,
ctrl: false,
alt: false,
meta: false,
},
});
#[derive(Clone, Copy, Debug)]
struct InputState {
left_pressed: bool,
right_pressed: bool,
middle_pressed: bool,
last_position: Vec2,
modifiers: Modifiers,
}
/// Push a winit window event to the buffer
///
/// Call this from the winit event loop
pub fn push_window_event(event: &WindowEvent) {
match event {
WindowEvent::MouseInput { state, button, .. } => {
let mouse_button = match button {
WinitMouseButton::Left => MouseButton::Left,
WinitMouseButton::Right => MouseButton::Right,
WinitMouseButton::Middle => MouseButton::Middle,
_ => return, // Ignore other buttons
};
if let Ok(mut input_state) = INPUT_STATE.lock() {
let position = input_state.last_position;
// Update button state
match mouse_button {
MouseButton::Left => input_state.left_pressed = *state == ElementState::Pressed,
MouseButton::Right => input_state.right_pressed = *state == ElementState::Pressed,
MouseButton::Middle => input_state.middle_pressed = *state == ElementState::Pressed,
}
if let Ok(mut buf) = BUFFER.lock() {
buf.push(RawWinitEvent::MouseButton {
button: mouse_button,
state: *state,
position,
});
}
}
}
WindowEvent::CursorMoved { position, .. } => {
let pos = Vec2::new(position.x as f32, position.y as f32);
if let Ok(mut input_state) = INPUT_STATE.lock() {
input_state.last_position = pos;
// Generate drag events for any pressed buttons
if input_state.left_pressed || input_state.right_pressed || input_state.middle_pressed {
if let Ok(mut buf) = BUFFER.lock() {
buf.push(RawWinitEvent::CursorMoved { position: pos });
}
}
}
}
WindowEvent::KeyboardInput { event: key_event, .. } => {
// Only handle physical keys
if let PhysicalKey::Code(key_code) = key_event.physical_key {
if let Ok(input_state) = INPUT_STATE.lock() {
if let Ok(mut buf) = BUFFER.lock() {
buf.push(RawWinitEvent::Keyboard {
key: key_code,
state: key_event.state,
modifiers: input_state.modifiers,
});
}
}
}
}
WindowEvent::ModifiersChanged(new_modifiers) => {
if let Ok(mut input_state) = INPUT_STATE.lock() {
input_state.modifiers = Modifiers {
shift: new_modifiers.state().shift_key(),
ctrl: new_modifiers.state().control_key(),
alt: new_modifiers.state().alt_key(),
meta: new_modifiers.state().super_key(),
};
}
}
WindowEvent::MouseWheel { delta, .. } => {
let scroll_delta = match delta {
MouseScrollDelta::LineDelta(x, y) => Vec2::new(*x, *y) * 20.0, // Scale line deltas
MouseScrollDelta::PixelDelta(pos) => Vec2::new(pos.x as f32, pos.y as f32),
};
if let Ok(input_state) = INPUT_STATE.lock() {
if let Ok(mut buf) = BUFFER.lock() {
buf.push(RawWinitEvent::MouseWheel {
delta: scroll_delta,
position: input_state.last_position,
});
}
}
}
_ => {}
}
}
/// Drain all buffered winit events and convert to InputEvents
///
/// Call this from your engine's input processing to consume events.
pub fn drain_as_input_events() -> Vec<InputEvent> {
BUFFER
.lock()
.ok()
.map(|mut b| {
std::mem::take(&mut *b)
.into_iter()
.filter_map(raw_to_input_event)
.collect()
})
.unwrap_or_default()
}
/// Convert a raw winit event to an engine InputEvent
fn raw_to_input_event(event: RawWinitEvent) -> Option<InputEvent> {
match event {
RawWinitEvent::MouseButton { button, state, position } => {
let phase = match state {
ElementState::Pressed => TouchPhase::Started,
ElementState::Released => TouchPhase::Ended,
};
Some(InputEvent::Mouse {
pos: position,
button,
phase,
})
}
RawWinitEvent::CursorMoved { position } => {
// Determine which button is pressed for drag events
let input_state = INPUT_STATE.lock().ok()?;
let button = if input_state.left_pressed {
MouseButton::Left
} else if input_state.right_pressed {
MouseButton::Right
} else if input_state.middle_pressed {
MouseButton::Middle
} else {
return None; // No button pressed, ignore
};
Some(InputEvent::Mouse {
pos: position,
button,
phase: TouchPhase::Moved,
})
}
RawWinitEvent::Keyboard { key, state, modifiers } => {
Some(InputEvent::Keyboard {
key,
pressed: state == ElementState::Pressed,
modifiers,
})
}
RawWinitEvent::MouseWheel { delta, position } => {
Some(InputEvent::MouseWheel {
delta,
pos: position,
})
}
}
}

View File

@@ -0,0 +1,10 @@
//! iOS platform support
//!
//! This module contains iOS-specific input capture code.
pub mod pencil_bridge;
pub use pencil_bridge::{
drain_as_input_events, drain_raw, pencil_point_received, swift_attach_pencil_capture,
RawPencilPoint,
};

View File

@@ -0,0 +1,103 @@
//! Apple Pencil input bridge for iOS
//!
//! This module captures raw Apple Pencil input via Swift/UIKit and converts
//! it to engine-agnostic InputEvents.
use crate::engine::input_events::{InputEvent, TouchPhase};
use glam::Vec2;
use std::sync::Mutex;
/// Raw pencil point data from Swift UITouch
///
/// This matches the C struct defined in PencilBridge.h
#[derive(Clone, Copy, Debug, Default)]
#[repr(C)] // Use C memory layout so Swift can interop
pub struct RawPencilPoint {
/// Screen X coordinate in points (not pixels)
pub x: f32,
/// Screen Y coordinate in points (not pixels)
pub y: f32,
/// Force/pressure (0.0 - 4.0 on Apple Pencil)
pub force: f32,
/// Altitude angle in radians (0 = flat, π/2 = perpendicular)
pub altitude: f32,
/// Azimuth angle in radians (rotation around vertical)
pub azimuth: f32,
/// iOS timestamp (seconds since system boot)
pub timestamp: f64,
/// Touch phase: 0=began, 1=moved, 2=ended
pub phase: u8,
}
/// Thread-safe buffer for pencil points
///
/// Swift's main thread pushes points here via C FFI.
/// Bevy's Update schedule drains them each frame.
static BUFFER: Mutex<Vec<RawPencilPoint>> = Mutex::new(Vec::new());
/// FFI function called from Swift when a pencil point is received
///
/// This is exposed as a C function so Swift can call it.
/// The `#[no_mangle]` prevents Rust from changing the function name.
#[no_mangle]
pub extern "C" fn pencil_point_received(point: RawPencilPoint) {
if let Ok(mut buf) = BUFFER.lock() {
buf.push(point);
}
}
/// Drain all buffered pencil points and convert to InputEvents
///
/// Call this from your Bevy Update system to consume input.
pub fn drain_as_input_events() -> Vec<InputEvent> {
BUFFER
.lock()
.ok()
.map(|mut b| {
std::mem::take(&mut *b)
.into_iter()
.map(raw_to_input_event)
.collect()
})
.unwrap_or_default()
}
/// Drain raw pencil points without conversion
///
/// Useful for debugging or custom processing.
pub fn drain_raw() -> Vec<RawPencilPoint> {
BUFFER
.lock()
.ok()
.map(|mut b| std::mem::take(&mut *b))
.unwrap_or_default()
}
/// Convert a raw pencil point to an engine InputEvent
fn raw_to_input_event(p: RawPencilPoint) -> InputEvent {
InputEvent::Stylus {
pos: Vec2::new(p.x, p.y),
pressure: p.force,
tilt: Vec2::new(p.altitude, p.azimuth),
phase: match p.phase {
0 => TouchPhase::Started,
1 => TouchPhase::Moved,
2 => TouchPhase::Ended,
_ => TouchPhase::Cancelled,
},
timestamp: p.timestamp,
}
}
/// Attach the pencil capture system to a UIView
///
/// This is only available on iOS. On other platforms, it's a no-op.
#[cfg(target_os = "ios")]
extern "C" {
pub fn swift_attach_pencil_capture(view: *mut std::ffi::c_void);
}
#[cfg(not(target_os = "ios"))]
pub unsafe fn swift_attach_pencil_capture(_: *mut std::ffi::c_void) {
// No-op on non-iOS platforms
}

View File

@@ -0,0 +1,43 @@
/**
* C header for Rust-Swift interop
*
* This defines the interface between Rust and Swift.
* Both sides include this header to ensure they agree on data types.
*/
#ifndef PENCIL_BRIDGE_H
#define PENCIL_BRIDGE_H
#include <stdint.h>
/**
* Raw pencil data from iOS UITouch
*
* This struct uses C types that both Rust and Swift understand.
* The memory layout must match exactly on both sides.
*/
typedef struct {
float x; // Screen X in points
float y; // Screen Y in points
float force; // Pressure (0.0 - 4.0)
float altitude; // Angle from screen (radians)
float azimuth; // Rotation angle (radians)
double timestamp; // iOS system timestamp
uint8_t phase; // 0=began, 1=moved, 2=ended
} RawPencilPoint;
/**
* Called from Swift when a pencil point is captured
*
* This is implemented in Rust (pencil_bridge.rs)
*/
void pencil_point_received(RawPencilPoint point);
/**
* Attach pencil capture to a UIView
*
* This is implemented in Swift (PencilCapture.swift)
*/
void swift_attach_pencil_capture(void* view);
#endif

View File

@@ -0,0 +1,52 @@
import UIKit
@_cdecl("swift_attach_pencil_capture")
func swiftAttachPencilCapture(_ viewPtr: UnsafeMutableRawPointer) {
DispatchQueue.main.async {
let view = Unmanaged<UIView>.fromOpaque(viewPtr).takeUnretainedValue()
let recognizer = PencilGestureRecognizer()
recognizer.cancelsTouchesInView = false
recognizer.delaysTouchesEnded = false
view.addGestureRecognizer(recognizer)
print("[Swift] Pencil capture attached")
}
}
class PencilGestureRecognizer: UIGestureRecognizer {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
state = .began
send(touches, event: event, phase: 0)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
state = .changed
send(touches, event: event, phase: 1)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
state = .ended
send(touches, event: event, phase: 2)
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
state = .cancelled
send(touches, event: event, phase: 2)
}
private func send(_ touches: Set<UITouch>, event: UIEvent?, phase: UInt8) {
for touch in touches where touch.type == .pencil {
for t in event?.coalescedTouches(for: touch) ?? [touch] {
let loc = t.preciseLocation(in: view)
pencil_point_received(RawPencilPoint(
x: Float(loc.x),
y: Float(loc.y),
force: Float(t.force),
altitude: Float(t.altitudeAngle),
azimuth: Float(t.azimuthAngle(in: view)),
timestamp: t.timestamp,
phase: phase
))
}
}
}
}

View File

@@ -0,0 +1,10 @@
//! Platform-specific input bridges
//!
//! This module contains platform-specific code for capturing input
//! and converting it to engine-agnostic InputEvents.
#[cfg(target_os = "ios")]
pub mod ios;
#[cfg(not(target_os = "ios"))]
pub mod desktop;

View File

@@ -0,0 +1,234 @@
//! Integration tests for EngineBridge command/event routing
use libmarathon::engine::{EngineBridge, EngineCommand, EngineCore, EngineEvent};
use libmarathon::networking::SessionId;
use std::time::Duration;
use tokio::time::timeout;
/// Test that commands sent from "Bevy side" reach the engine
#[tokio::test]
async fn test_command_routing() {
let (bridge, handle) = EngineBridge::new();
// Spawn engine in background
let engine_handle = tokio::spawn(async move {
// Run engine for a short time
let core = EngineCore::new(handle, ":memory:");
timeout(Duration::from_millis(100), core.run())
.await
.ok();
});
// Give engine time to start
tokio::time::sleep(Duration::from_millis(10)).await;
// Send a command from "Bevy side"
let session_id = SessionId::new();
bridge.send_command(EngineCommand::StartNetworking {
session_id: session_id.clone(),
});
// Give engine time to process
tokio::time::sleep(Duration::from_millis(50)).await;
// Poll events
let events = bridge.poll_events();
// Verify we got a NetworkingStarted event
assert!(!events.is_empty(), "Should receive at least one event");
let has_networking_started = events.iter().any(|e| {
matches!(
e,
EngineEvent::NetworkingStarted {
session_id: sid,
..
} if sid == &session_id
)
});
assert!(
has_networking_started,
"Should receive NetworkingStarted event"
);
// Cleanup
drop(bridge);
let _ = engine_handle.await;
}
/// Test that events from engine reach "Bevy side"
#[tokio::test]
async fn test_event_routing() {
let (bridge, handle) = EngineBridge::new();
// Spawn engine
let engine_handle = tokio::spawn(async move {
let core = EngineCore::new(handle, ":memory:");
timeout(Duration::from_millis(100), core.run())
.await
.ok();
});
tokio::time::sleep(Duration::from_millis(10)).await;
// Send StartNetworking command
let session_id = SessionId::new();
bridge.send_command(EngineCommand::StartNetworking {
session_id: session_id.clone(),
});
tokio::time::sleep(Duration::from_millis(50)).await;
// Poll events multiple times to verify queue works
let events1 = bridge.poll_events();
let events2 = bridge.poll_events();
assert!(!events1.is_empty(), "First poll should return events");
assert!(
events2.is_empty(),
"Second poll should be empty (events already drained)"
);
// Cleanup
drop(bridge);
let _ = engine_handle.await;
}
/// Test full lifecycle: Start → Stop networking
#[tokio::test]
async fn test_networking_lifecycle() {
let (bridge, handle) = EngineBridge::new();
let engine_handle = tokio::spawn(async move {
let core = EngineCore::new(handle, ":memory:");
timeout(Duration::from_millis(200), core.run())
.await
.ok();
});
tokio::time::sleep(Duration::from_millis(10)).await;
// Start networking
let session_id = SessionId::new();
bridge.send_command(EngineCommand::StartNetworking {
session_id: session_id.clone(),
});
tokio::time::sleep(Duration::from_millis(50)).await;
let events = bridge.poll_events();
assert!(
events
.iter()
.any(|e| matches!(e, EngineEvent::NetworkingStarted { .. })),
"Should receive NetworkingStarted"
);
// Stop networking
bridge.send_command(EngineCommand::StopNetworking);
tokio::time::sleep(Duration::from_millis(50)).await;
let events = bridge.poll_events();
assert!(
events
.iter()
.any(|e| matches!(e, EngineEvent::NetworkingStopped)),
"Should receive NetworkingStopped"
);
// Cleanup
drop(bridge);
let _ = engine_handle.await;
}
/// Test JoinSession command routing
#[tokio::test]
async fn test_join_session_routing() {
let (bridge, handle) = EngineBridge::new();
let engine_handle = tokio::spawn(async move {
let core = EngineCore::new(handle, ":memory:");
timeout(Duration::from_millis(200), core.run())
.await
.ok();
});
tokio::time::sleep(Duration::from_millis(10)).await;
// Join a new session (should start networking)
let session_id = SessionId::new();
bridge.send_command(EngineCommand::JoinSession {
session_id: session_id.clone(),
});
tokio::time::sleep(Duration::from_millis(50)).await;
let events = bridge.poll_events();
assert!(
events.iter().any(|e| {
matches!(
e,
EngineEvent::NetworkingStarted {
session_id: sid,
..
} if sid == &session_id
)
}),
"JoinSession should start networking"
);
// Cleanup
drop(bridge);
let _ = engine_handle.await;
}
/// Test that multiple commands are processed in order
#[tokio::test]
async fn test_command_ordering() {
let (bridge, handle) = EngineBridge::new();
let engine_handle = tokio::spawn(async move {
let core = EngineCore::new(handle, ":memory:");
timeout(Duration::from_millis(200), core.run())
.await
.ok();
});
tokio::time::sleep(Duration::from_millis(10)).await;
// Send multiple commands
let session1 = SessionId::new();
let session2 = SessionId::new();
bridge.send_command(EngineCommand::StartNetworking {
session_id: session1.clone(),
});
bridge.send_command(EngineCommand::StopNetworking);
bridge.send_command(EngineCommand::JoinSession {
session_id: session2.clone(),
});
tokio::time::sleep(Duration::from_millis(100)).await;
let events = bridge.poll_events();
// Should see: NetworkingStarted(session1), NetworkingStopped, NetworkingStarted(session2)
let started_events: Vec<_> = events
.iter()
.filter(|e| matches!(e, EngineEvent::NetworkingStarted { .. }))
.collect();
let stopped_events: Vec<_> = events
.iter()
.filter(|e| matches!(e, EngineEvent::NetworkingStopped))
.collect();
assert_eq!(started_events.len(), 2, "Should have 2 NetworkingStarted events");
assert_eq!(stopped_events.len(), 1, "Should have 1 NetworkingStopped event");
// Cleanup
drop(bridge);
let _ = engine_handle.await;
}

View File

@@ -3,7 +3,7 @@
//! Tests the gossip bridge channel infrastructure. Full iroh-gossip integration
//! will be tested in Phase 3.5.
use lib::networking::*;
use libmarathon::networking::*;
#[test]
fn test_gossip_bridge_creation() {
@@ -15,7 +15,7 @@ fn test_gossip_bridge_creation() {
#[test]
fn test_gossip_bridge_send() {
use lib::networking::{
use libmarathon::networking::{
JoinType,
SessionId,
};

View File

@@ -1,5 +1,5 @@
use chrono::Datelike;
use lib::{
use libmarathon::{
ChatDb,
Result,
};

View File

@@ -4,7 +4,7 @@
//! their mathematical properties under all possible inputs and operation
//! sequences.
use lib::{
use libmarathon::{
networking::{
NodeId,
VectorClock,

View File

@@ -39,7 +39,7 @@ use iroh_gossip::{
net::Gossip,
proto::TopicId,
};
use lib::{
use libmarathon::{
networking::{
EntityLockRegistry,
GossipBridge,
@@ -175,7 +175,7 @@ mod test_utils {
let data = data_result.optional()?;
if let Some(bytes) = data {
use lib::persistence::reflection::deserialize_component_typed;
use libmarathon::persistence::reflection::deserialize_component_typed;
let reflected = deserialize_component_typed(&bytes, component_type, type_registry)?;
if let Some(concrete) = reflected.try_downcast_ref::<T>() {
@@ -1081,8 +1081,8 @@ async fn test_lock_heartbeat_renewal() -> Result<()> {
{
let registry1 = app1.world().resource::<EntityLockRegistry>();
let registry2 = app2.world().resource::<EntityLockRegistry>();
assert!(registry1.is_locked(entity_id), "Lock should exist on node 1");
assert!(registry2.is_locked(entity_id), "Lock should exist on node 2");
assert!(registry1.is_locked(entity_id, node1_id), "Lock should exist on node 1");
assert!(registry2.is_locked(entity_id, node2_id), "Lock should exist on node 2");
println!("✓ Lock acquired on both nodes");
}
@@ -1120,12 +1120,12 @@ async fn test_lock_heartbeat_renewal() -> Result<()> {
let registry1 = app1.world().resource::<EntityLockRegistry>();
let registry2 = app2.world().resource::<EntityLockRegistry>();
assert!(
registry1.is_locked(entity_id),
registry1.is_locked(entity_id, node1_id),
"Lock should persist on node 1 after heartbeat {}",
i + 1
);
assert!(
registry2.is_locked(entity_id),
registry2.is_locked(entity_id, node2_id),
"Lock should persist on node 2 after heartbeat {}",
i + 1
);
@@ -1205,7 +1205,7 @@ async fn test_lock_heartbeat_expiration() -> Result<()> {
// Verify lock acquired
wait_for_sync(&mut app1, &mut app2, Duration::from_secs(2), |_, w2| {
let registry2 = w2.resource::<EntityLockRegistry>();
registry2.is_locked(entity_id)
registry2.is_locked(entity_id, node2_id)
})
.await?;
println!("✓ Lock acquired and propagated");
@@ -1236,7 +1236,7 @@ async fn test_lock_heartbeat_expiration() -> Result<()> {
{
let registry = app2.world().resource::<EntityLockRegistry>();
assert!(
!registry.is_locked(entity_id),
!registry.is_locked(entity_id, node2_id),
"Lock should be expired on node 2 after cleanup"
);
println!("✓ Lock expired on node 2 after 5 seconds without heartbeat");
@@ -1315,7 +1315,7 @@ async fn test_lock_release_stops_heartbeats() -> Result<()> {
// Wait for lock to propagate
wait_for_sync(&mut app1, &mut app2, Duration::from_secs(2), |_, w2| {
let registry2 = w2.resource::<EntityLockRegistry>();
registry2.is_locked(entity_id)
registry2.is_locked(entity_id, node2_id)
})
.await?;
println!("✓ Lock acquired and propagated");
@@ -1349,7 +1349,7 @@ async fn test_lock_release_stops_heartbeats() -> Result<()> {
// Wait for release to propagate to node 2
wait_for_sync(&mut app1, &mut app2, Duration::from_secs(3), |_, w2| {
let registry2 = w2.resource::<EntityLockRegistry>();
!registry2.is_locked(entity_id)
!registry2.is_locked(entity_id, node2_id)
})
.await?;
println!("✓ Lock release propagated to node 2");

View File

@@ -6,7 +6,7 @@ use std::sync::{
};
use bevy::prelude::*;
use lib::networking::{
use libmarathon::networking::{
NetworkedEntity,
NetworkedTransform,
Synced,
@@ -21,7 +21,7 @@ fn test_transform_change_detection_basic() {
// Add the auto_detect system
app.add_systems(
Update,
lib::networking::auto_detect_transform_changes_system,
libmarathon::networking::auto_detect_transform_changes_system,
);
// Add a test system that runs AFTER auto_detect to check if NetworkedEntity was

View File

@@ -12,7 +12,7 @@ quote = "1.0"
proc-macro2 = "1.0"
[dev-dependencies]
lib = { path = "../lib" }
libmarathon = { path = "../libmarathon" }
bevy = { workspace = true }
serde = { workspace = true }
bincode = "1.3"

View File

@@ -31,11 +31,11 @@ impl SyncStrategy {
fn to_tokens(&self) -> proc_macro2::TokenStream {
match self {
| SyncStrategy::LastWriteWins => {
quote! { lib::networking::SyncStrategy::LastWriteWins }
quote! { libmarathon::networking::SyncStrategy::LastWriteWins }
},
| SyncStrategy::Set => quote! { lib::networking::SyncStrategy::Set },
| SyncStrategy::Sequence => quote! { lib::networking::SyncStrategy::Sequence },
| SyncStrategy::Custom => quote! { lib::networking::SyncStrategy::Custom },
| SyncStrategy::Set => quote! { libmarathon::networking::SyncStrategy::Set },
| SyncStrategy::Sequence => quote! { libmarathon::networking::SyncStrategy::Sequence },
| SyncStrategy::Custom => quote! { libmarathon::networking::SyncStrategy::Custom },
}
}
}
@@ -124,7 +124,7 @@ impl SyncAttributes {
/// # Example
/// ```ignore
/// use bevy::prelude::*;
/// use lib::networking::Synced;
/// use libmarathon::networking::Synced;
/// use sync_macros::Synced as SyncedDerive;
///
/// #[derive(Component, Reflect, Clone, serde::Serialize, serde::Deserialize)]
@@ -160,9 +160,9 @@ pub fn derive_synced(input: TokenStream) -> TokenStream {
let merge_impl = generate_merge(&input, &attrs.strategy);
let expanded = quote! {
impl lib::networking::SyncComponent for #name {
impl libmarathon::networking::SyncComponent for #name {
const VERSION: u32 = #version;
const STRATEGY: lib::networking::SyncStrategy = #strategy_tokens;
const STRATEGY: libmarathon::networking::SyncStrategy = #strategy_tokens;
#[inline]
fn serialize_sync(&self) -> anyhow::Result<Vec<u8>> {
@@ -175,7 +175,7 @@ pub fn derive_synced(input: TokenStream) -> TokenStream {
}
#[inline]
fn merge(&mut self, remote: Self, clock_cmp: lib::networking::ClockComparison) -> lib::networking::ComponentMergeDecision {
fn merge(&mut self, remote: Self, clock_cmp: libmarathon::networking::ClockComparison) -> libmarathon::networking::ComponentMergeDecision {
#merge_impl
}
}
@@ -235,19 +235,19 @@ fn generate_lww_merge(_input: &DeriveInput) -> proc_macro2::TokenStream {
use tracing::info;
match clock_cmp {
lib::networking::ClockComparison::RemoteNewer => {
libmarathon::networking::ClockComparison::RemoteNewer => {
info!(
component = std::any::type_name::<Self>(),
?clock_cmp,
"Taking remote (newer)"
);
*self = remote;
lib::networking::ComponentMergeDecision::TookRemote
libmarathon::networking::ComponentMergeDecision::TookRemote
}
lib::networking::ClockComparison::LocalNewer => {
lib::networking::ComponentMergeDecision::KeptLocal
libmarathon::networking::ClockComparison::LocalNewer => {
libmarathon::networking::ComponentMergeDecision::KeptLocal
}
lib::networking::ClockComparison::Concurrent => {
libmarathon::networking::ClockComparison::Concurrent => {
// Tiebreaker: Compare serialized representations for deterministic choice
// In a real implementation, we'd use node_id, but for now use a simple hash
#hash_tiebreaker
@@ -259,9 +259,9 @@ fn generate_lww_merge(_input: &DeriveInput) -> proc_macro2::TokenStream {
"Taking remote (concurrent, tiebreaker)"
);
*self = remote;
lib::networking::ComponentMergeDecision::TookRemote
libmarathon::networking::ComponentMergeDecision::TookRemote
} else {
lib::networking::ComponentMergeDecision::KeptLocal
libmarathon::networking::ComponentMergeDecision::KeptLocal
}
}
}
@@ -292,23 +292,23 @@ fn generate_set_merge(_input: &DeriveInput) -> proc_macro2::TokenStream {
// the component to expose merge() method or implement it directly
match clock_cmp {
lib::networking::ClockComparison::RemoteNewer => {
libmarathon::networking::ClockComparison::RemoteNewer => {
*self = remote;
lib::networking::ComponentMergeDecision::TookRemote
libmarathon::networking::ComponentMergeDecision::TookRemote
}
lib::networking::ClockComparison::LocalNewer => {
lib::networking::ComponentMergeDecision::KeptLocal
libmarathon::networking::ClockComparison::LocalNewer => {
libmarathon::networking::ComponentMergeDecision::KeptLocal
}
lib::networking::ClockComparison::Concurrent => {
libmarathon::networking::ClockComparison::Concurrent => {
// In a full implementation, we would merge the OrSet here
// For now, use LWW with tiebreaker as fallback
#hash_tiebreaker
if remote_hash > local_hash {
*self = remote;
lib::networking::ComponentMergeDecision::TookRemote
libmarathon::networking::ComponentMergeDecision::TookRemote
} else {
lib::networking::ComponentMergeDecision::KeptLocal
libmarathon::networking::ComponentMergeDecision::KeptLocal
}
}
}
@@ -338,23 +338,23 @@ fn generate_sequence_merge(_input: &DeriveInput) -> proc_macro2::TokenStream {
// the component to expose merge() method or implement it directly
match clock_cmp {
lib::networking::ClockComparison::RemoteNewer => {
libmarathon::networking::ClockComparison::RemoteNewer => {
*self = remote;
lib::networking::ComponentMergeDecision::TookRemote
libmarathon::networking::ComponentMergeDecision::TookRemote
}
lib::networking::ClockComparison::LocalNewer => {
lib::networking::ComponentMergeDecision::KeptLocal
libmarathon::networking::ClockComparison::LocalNewer => {
libmarathon::networking::ComponentMergeDecision::KeptLocal
}
lib::networking::ClockComparison::Concurrent => {
libmarathon::networking::ClockComparison::Concurrent => {
// In a full implementation, we would merge the Rga here
// For now, use LWW with tiebreaker as fallback
#hash_tiebreaker
if remote_hash > local_hash {
*self = remote;
lib::networking::ComponentMergeDecision::TookRemote
libmarathon::networking::ComponentMergeDecision::TookRemote
} else {
lib::networking::ComponentMergeDecision::KeptLocal
libmarathon::networking::ComponentMergeDecision::KeptLocal
}
}
}
@@ -371,6 +371,6 @@ fn generate_custom_merge(input: &DeriveInput) -> proc_macro2::TokenStream {
stringify!(#name)
)
);
lib::networking::ComponentMergeDecision::KeptLocal
libmarathon::networking::ComponentMergeDecision::KeptLocal
}
}

View File

@@ -1,6 +1,6 @@
/// Basic tests for the Synced derive macro
use bevy::prelude::*;
use lib::networking::{
use libmarathon::networking::{
ClockComparison,
ComponentMergeDecision,
SyncComponent,