From da886452bd0a0e87ab7afe387fed8c0ef3a15847 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Wed, 24 Dec 2025 11:50:56 +0000 Subject: [PATCH] Add headless mode for automated testing Implemented headless mode using MinimalPlugins and ScheduleRunnerPlugin to enable running the app without rendering, controlled via Unix socket. Changes: - Added conditional compilation based on 'headless' feature flag - Use MinimalPlugins with 60 FPS ScheduleRunner in headless mode - Skip rendering plugins (Camera, Rendering, DebugUI, SessionUI) - Made cube mesh/material assets optional for headless spawning - Direct NetworkingPlugin + PersistencePlugin instead of MarathonPlugin - Use app.run() instead of platform executor in headless mode This enables: - Running multiple instances for multi-client testing - Automated testing via marathonctl without GUI overhead - Background server instances for development - CI/CD integration for networking tests Refs #131, #132 Signed-off-by: Sienna Meridian Satterwhite --- crates/app/src/cube.rs | 27 +++++---- crates/app/src/main.rs | 125 +++++++++++++++++++++++++++++------------ 2 files changed, 106 insertions(+), 46 deletions(-) diff --git a/crates/app/src/cube.rs b/crates/app/src/cube.rs index 2f56526..84e6b42 100644 --- a/crates/app/src/cube.rs +++ b/crates/app/src/cube.rs @@ -50,8 +50,8 @@ impl Plugin for CubePlugin { fn handle_spawn_cube( mut commands: Commands, mut messages: MessageReader, - mut meshes: ResMut>, - mut materials: ResMut>, + mut meshes: Option>>, + mut materials: Option>>, node_clock: Res, ) { for event in messages.read() { @@ -60,16 +60,8 @@ fn handle_spawn_cube( info!("Spawning cube {} at {:?}", entity_id, event.position); - commands.spawn(( + let mut entity = 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 @@ -81,6 +73,19 @@ fn handle_spawn_cube( // Sync marker Synced, )); + + // Only add rendering components if assets are available (non-headless mode) + if let (Some(ref mut meshes), Some(ref mut materials)) = (meshes.as_mut(), materials.as_mut()) { + entity.insert(( + 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() + })), + )); + } } } diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 43766e5..037e9f7 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -11,7 +11,13 @@ use libmarathon::{ persistence::PersistenceConfig, }; +#[cfg(feature = "headless")] +use bevy::app::ScheduleRunnerPlugin; +#[cfg(feature = "headless")] +use std::time::Duration; + mod camera; +mod control; mod cube; mod debug_ui; mod engine_bridge; @@ -115,46 +121,95 @@ fn main() { eprintln!(">>> Inserting EngineBridge resource"); app.insert_resource(engine_bridge); - // Use DefaultPlugins but disable winit/window/input (we own those) - eprintln!(">>> Adding DefaultPlugins"); - app.add_plugins( - DefaultPlugins - .build() - .disable::() // Using tracing-subscriber - .disable::() // We own winit - .disable::() // We own the window - .disable::() // We provide InputEvents directly - .disable::(), // We handle gamepad input ourselves - ); - eprintln!(">>> DefaultPlugins added"); + // Plugin setup based on headless vs rendering mode + #[cfg(not(feature = "headless"))] + { + info!("Adding DefaultPlugins (rendering mode)"); + app.add_plugins( + DefaultPlugins + .build() + .disable::() // Using tracing-subscriber + .disable::() // We own winit + .disable::() // We own the window + .disable::() // We provide InputEvents directly + .disable::(), // We handle gamepad input ourselves + ); + info!("DefaultPlugins added"); + } - // Marathon core plugins (networking, debug UI, persistence) - eprintln!(">>> Adding MarathonPlugin"); - app.add_plugins(libmarathon::MarathonPlugin::new( - APP_NAME, - PersistenceConfig { - flush_interval_secs: 2, - checkpoint_interval_secs: 30, - battery_adaptive: true, - ..Default::default() - }, - )); - eprintln!(">>> MarathonPlugin added"); + #[cfg(feature = "headless")] + { + info!("Adding MinimalPlugins (headless mode)"); + app.add_plugins( + MinimalPlugins.set(ScheduleRunnerPlugin::run_loop( + Duration::from_secs_f64(1.0 / 60.0), // 60 FPS + )), + ); + info!("MinimalPlugins added"); + } + + // Marathon core plugins based on mode + #[cfg(not(feature = "headless"))] + { + info!("Adding MarathonPlugin (with debug UI)"); + app.add_plugins(libmarathon::MarathonPlugin::new( + APP_NAME, + PersistenceConfig { + flush_interval_secs: 2, + checkpoint_interval_secs: 30, + battery_adaptive: true, + ..Default::default() + }, + )); + } + + #[cfg(feature = "headless")] + { + info!("Adding networking and persistence (headless, no UI)"); + app.add_plugins(libmarathon::networking::NetworkingPlugin::new(Default::default())); + app.add_plugins(libmarathon::persistence::PersistencePlugin::with_config( + db_path.clone(), + PersistenceConfig { + flush_interval_secs: 2, + checkpoint_interval_secs: 30, + battery_adaptive: true, + ..Default::default() + }, + )); + } + + info!("Marathon plugins added"); // App-specific bridge for polling engine events - eprintln!(">>> Adding app plugins"); + info!("Adding app plugins"); app.add_plugins(EngineBridgePlugin); - 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); - app.add_plugins(SessionUiPlugin); app.add_systems(Startup, initialize_offline_resources); - eprintln!(">>> All plugins added"); + app.add_systems(Startup, control::start_control_socket_system); - eprintln!(">>> Running executor"); - libmarathon::platform::run_executor(app).expect("Failed to run executor"); - eprintln!(">>> Executor returned (should never reach here)"); + // Rendering-only plugins + #[cfg(not(feature = "headless"))] + { + app.add_plugins(CameraPlugin); + app.add_plugins(RenderingPlugin); + app.add_plugins(input::InputHandlerPlugin); + app.add_plugins(SelectionPlugin); + app.add_plugins(DebugUiPlugin); + app.add_plugins(SessionUiPlugin); + } + + info!("All plugins added"); + + // Run the app based on mode + #[cfg(not(feature = "headless"))] + { + info!("Running platform executor (rendering mode)"); + libmarathon::platform::run_executor(app).expect("Failed to run executor"); + } + + #[cfg(feature = "headless")] + { + info!("Running headless app loop"); + app.run(); + } }