chore: honestly fixed so much and forgot to commit

Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
This commit is contained in:
2025-12-28 17:39:27 +00:00
parent f9f289f5b2
commit d1d3aec8aa
47 changed files with 2248 additions and 438 deletions

View File

@@ -17,11 +17,25 @@ use uuid::Uuid;
#[derive(Resource)]
pub struct ControlSocketPath(pub String);
/// Resource holding the shutdown sender for control socket
#[derive(Resource)]
pub struct ControlSocketShutdown(Option<Sender<()>>);
pub fn cleanup_control_socket(
mut exit_events: MessageReader<bevy::app::AppExit>,
socket_path: Option<Res<ControlSocketPath>>,
shutdown: Option<Res<ControlSocketShutdown>>,
) {
for _ in exit_events.read() {
// Send shutdown signal to control socket thread
if let Some(ref shutdown_res) = shutdown {
if let Some(ref sender) = shutdown_res.0 {
info!("Sending shutdown signal to control socket");
let _ = sender.send(());
}
}
// Clean up socket file
if let Some(ref path) = socket_path {
info!("Cleaning up control socket at {}", path.0);
let _ = std::fs::remove_file(&path.0);
@@ -87,6 +101,10 @@ pub fn start_control_socket_system(
let app_queue = AppCommandQueue::new();
commands.insert_resource(app_queue.clone());
// Create shutdown channel
let (shutdown_tx, shutdown_rx) = unbounded::<()>();
commands.insert_resource(ControlSocketShutdown(Some(shutdown_tx)));
// Clone bridge and queue for the async task
let bridge = bridge.clone();
let queue = app_queue;
@@ -109,14 +127,25 @@ pub fn start_control_socket_system(
}
};
// Accept connections in a loop
// Accept connections in a loop with shutdown support
loop {
match listener.accept().await {
Ok((mut stream, _addr)) => {
let bridge = bridge.clone();
tokio::select! {
// Check for shutdown signal
_ = tokio::task::spawn_blocking({
let rx = shutdown_rx.clone();
move || rx.try_recv()
}) => {
info!("Control socket received shutdown signal");
break;
}
// Accept new connection
result = listener.accept() => {
match result {
Ok((mut stream, _addr)) => {
let bridge = bridge.clone();
let queue_clone = queue.clone();
tokio::spawn(async move {
let queue_clone = queue.clone();
tokio::spawn(async move {
// Read command length
let mut len_buf = [0u8; 4];
if let Err(e) = stream.read_exact(&mut len_buf).await {
@@ -155,12 +184,15 @@ pub fn start_control_socket_system(
error!("Failed to send response: {}", e);
}
});
}
Err(e) => {
error!("Failed to accept connection: {}", e);
}
Err(e) => {
error!("Failed to accept connection: {}", e);
}
}
}
}
}
info!("Control socket server shut down cleanly");
});
});
}
@@ -270,4 +302,7 @@ async fn send_response(
// No-op stubs for iOS and release builds
#[cfg(any(target_os = "ios", not(debug_assertions)))]
pub fn start_control_socket_system() {}
pub fn start_control_socket_system(mut commands: Commands) {
// Insert empty shutdown resource for consistency
commands.insert_resource(ControlSocketShutdown(None));
}

View File

@@ -1,27 +1,37 @@
//! Cube entity management
use bevy::prelude::*;
use libmarathon::{
networking::{
NetworkEntityMap,
NetworkedEntity,
NetworkedSelection,
NetworkedTransform,
NodeVectorClock,
Synced,
},
persistence::Persisted,
};
use serde::{
Deserialize,
Serialize,
};
use libmarathon::networking::{NetworkEntityMap, Synced};
use uuid::Uuid;
/// Marker component for the replicated cube
#[derive(Component, Reflect, Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[reflect(Component)]
pub struct CubeMarker;
///
/// This component contains all the data needed for rendering a cube.
/// The `#[synced]` attribute automatically handles network synchronization.
#[macros::synced]
pub struct CubeMarker {
/// RGB color values (0.0 to 1.0)
pub color_r: f32,
pub color_g: f32,
pub color_b: f32,
pub size: f32,
}
impl CubeMarker {
pub fn with_color(color: Color, size: f32) -> Self {
let [r, g, b, _] = color.to_linear().to_f32_array();
Self {
color_r: r,
color_g: g,
color_b: b,
size,
}
}
pub fn color(&self) -> Color {
Color::srgb(self.color_r, self.color_g, self.color_b)
}
}
/// Message to spawn a new cube at a specific position
#[derive(Message)]
@@ -39,10 +49,33 @@ pub struct CubePlugin;
impl Plugin for CubePlugin {
fn build(&self, app: &mut App) {
app.register_type::<CubeMarker>()
.add_message::<SpawnCubeEvent>()
app.add_message::<SpawnCubeEvent>()
.add_message::<DeleteCubeEvent>()
.add_systems(Update, (handle_spawn_cube, handle_delete_cube));
.add_systems(Update, (
handle_spawn_cube,
handle_delete_cube,
add_cube_rendering_system, // Custom rendering!
));
}
}
/// Custom rendering system - detects Added<CubeMarker> and adds mesh/material
fn add_cube_rendering_system(
mut commands: Commands,
query: Query<(Entity, &CubeMarker), Added<CubeMarker>>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
for (entity, cube) in &query {
commands.entity(entity).insert((
Mesh3d(meshes.add(Cuboid::new(cube.size, cube.size, cube.size))),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: cube.color(), // Use the color() helper method
perceptual_roughness: 0.7,
metallic: 0.3,
..default()
})),
));
}
}
@@ -50,42 +83,16 @@ impl Plugin for CubePlugin {
fn handle_spawn_cube(
mut commands: Commands,
mut messages: MessageReader<SpawnCubeEvent>,
mut meshes: Option<ResMut<Assets<Mesh>>>,
mut materials: Option<ResMut<Assets<StandardMaterial>>>,
node_clock: Res<NodeVectorClock>,
) {
for event in messages.read() {
let entity_id = Uuid::new_v4();
let node_id = node_clock.node_id;
info!("Spawning cube at {:?}", event.position);
info!("Spawning cube {} at {:?}", entity_id, event.position);
let mut entity = commands.spawn((
CubeMarker,
commands.spawn((
CubeMarker::with_color(Color::srgb(0.8, 0.3, 0.6), 1.0),
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,
Synced, // Auto-adds NetworkedEntity, Persisted, NetworkedTransform
));
// 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()
})),
));
}
}
}
@@ -97,8 +104,14 @@ fn handle_delete_cube(
) {
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();
info!("Marking cube {} for deletion", event.entity_id);
// Add ToDelete marker - the handle_local_deletions_system will:
// 1. Increment vector clock
// 2. Create Delete operation
// 3. Record tombstone
// 4. Broadcast deletion to peers
// 5. Despawn entity locally
commands.entity(bevy_entity).insert(libmarathon::networking::ToDelete);
} else {
warn!("Attempted to delete unknown cube {}", event.entity_id);
}

View File

@@ -43,11 +43,10 @@ fn render_debug_ui(
// Node information
if let Some(clock) = &node_clock {
ui.label(format!("Node ID: {}", &clock.node_id.to_string()[..8]));
// Show the current node's clock value (timestamp)
let current_timestamp =
clock.clock.clocks.get(&clock.node_id).copied().unwrap_or(0);
ui.label(format!("Clock: {}", current_timestamp));
ui.label(format!("Known nodes: {}", clock.clock.clocks.len()));
// Show the sum of all timestamps (total operations across all nodes)
let total_ops: u64 = clock.clock.timestamps.values().sum();
ui.label(format!("Clock: {} (total ops)", total_ops));
ui.label(format!("Known nodes: {}", clock.clock.node_count()));
} else {
ui.label("Node: Not initialized");
}

View File

@@ -53,6 +53,7 @@ fn poll_engine_events(
let events = (*bridge).poll_events();
if !events.is_empty() {
debug!("Polling {} engine events", events.len());
for event in events {
match event {
EngineEvent::NetworkingInitializing { session_id, status } => {
@@ -113,10 +114,17 @@ fn poll_engine_events(
}
EngineEvent::PeerJoined { node_id } => {
info!("Peer joined: {}", node_id);
// Initialize peer in vector clock so it shows up in UI immediately
node_clock.clock.timestamps.entry(node_id).or_insert(0);
// TODO(Phase 3.3): Trigger sync
}
EngineEvent::PeerLeft { node_id } => {
info!("Peer left: {}", node_id);
// Remove peer from vector clock
node_clock.clock.timestamps.remove(&node_id);
}
EngineEvent::LockAcquired { entity_id, holder } => {
debug!("Lock acquired: entity={}, holder={}", entity_id, holder);
@@ -165,20 +173,15 @@ fn poll_engine_events(
}
}
/// Handle app exit to stop networking immediately
/// Handle app exit - send shutdown signal to EngineCore
fn handle_app_exit(
mut exit_events: MessageReader<bevy::app::AppExit>,
bridge: Res<EngineBridge>,
current_session: Res<CurrentSession>,
) {
for _ in exit_events.read() {
// If networking is active, send stop command
// Don't wait - the task will be aborted when the runtime shuts down
if current_session.session.state == SessionState::Active
|| current_session.session.state == SessionState::Joining {
info!("App exiting, aborting networking immediately");
bridge.send_command(EngineCommand::StopNetworking);
// Don't sleep - just let the app exit. The tokio runtime will clean up.
}
info!("App exiting - sending Shutdown command to EngineCore");
bridge.send_command(EngineCommand::Shutdown);
// The EngineCore will receive the Shutdown command and gracefully exit
// its event loop, allowing the tokio runtime thread to complete
}
}

View File

@@ -6,9 +6,14 @@ use bevy::prelude::*;
use libmarathon::{
engine::GameAction,
platform::input::InputController,
networking::{EntityLockRegistry, NetworkedEntity, NetworkedSelection, NodeVectorClock},
networking::{
EntityLockRegistry, LocalSelection, NetworkedEntity,
NodeVectorClock,
},
};
use crate::cube::CubeMarker;
use super::event_buffer::InputEventBuffer;
pub struct InputHandlerPlugin;
@@ -16,7 +21,9 @@ pub struct InputHandlerPlugin;
impl Plugin for InputHandlerPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<InputControllerResource>()
.add_systems(Update, handle_game_actions);
// handle_game_actions updates selection - must run before release_locks_on_deselection_system
.add_systems(Update, handle_game_actions.before(libmarathon::networking::release_locks_on_deselection_system))
.add_systems(PostUpdate, update_lock_visuals);
}
}
@@ -46,9 +53,10 @@ fn to_bevy_vec2(v: glam::Vec2) -> bevy::math::Vec2 {
fn handle_game_actions(
input_buffer: Res<InputEventBuffer>,
mut controller_res: ResMut<InputControllerResource>,
mut lock_registry: ResMut<EntityLockRegistry>,
lock_registry: Res<EntityLockRegistry>,
node_clock: Res<NodeVectorClock>,
mut cube_query: Query<(&NetworkedEntity, &mut Transform, &mut NetworkedSelection), With<crate::cube::CubeMarker>>,
mut selection: ResMut<LocalSelection>,
mut cube_query: Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
camera_query: Query<(&Camera, &GlobalTransform)>,
window_query: Query<&Window>,
) {
@@ -65,14 +73,23 @@ fn handle_game_actions(
for action in all_actions {
match action {
GameAction::SelectEntity { position } => {
apply_select_entity(
// Do raycasting to find which entity (if any) was clicked
let entity_id = raycast_entity(
position,
&mut lock_registry,
node_id,
&mut cube_query,
&cube_query,
&camera_query,
&window_query,
);
// Update selection
// The release_locks_on_deselection_system will automatically handle lock changes
selection.clear();
if let Some(id) = entity_id {
selection.insert(id);
info!("Selected entity {}", id);
} else {
info!("Deselected all entities");
}
}
GameAction::MoveEntity { delta } => {
@@ -98,32 +115,32 @@ fn handle_game_actions(
}
}
/// Apply SelectEntity action - raycast to find clicked cube and select it
fn apply_select_entity(
/// Raycast to find which entity was clicked
///
/// Returns the network ID of the closest entity hit by the ray, or None if nothing was hit.
fn raycast_entity(
position: glam::Vec2,
lock_registry: &mut EntityLockRegistry,
node_id: uuid::Uuid,
cube_query: &mut Query<(&NetworkedEntity, &mut Transform, &mut NetworkedSelection), With<crate::cube::CubeMarker>>,
cube_query: &Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
camera_query: &Query<(&Camera, &GlobalTransform)>,
window_query: &Query<&Window>,
) {
) -> Option<uuid::Uuid> {
// Get the camera and window
let Ok((camera, camera_transform)) = camera_query.single() else {
return;
return None;
};
let Ok(window) = window_query.single() else {
return;
return None;
};
// Convert screen position to world ray
let Some(ray) = screen_to_world_ray(position, camera, camera_transform, window) else {
return;
return None;
};
// Find the closest cube hit by the ray
let mut closest_hit: Option<(uuid::Uuid, f32)> = None;
for (networked, transform, _) in cube_query.iter() {
for (networked, transform) in cube_query.iter() {
// Test ray against cube AABB (1x1x1 cube)
if let Some(distance) = ray_aabb_intersection(
ray.origin,
@@ -137,31 +154,7 @@ fn apply_select_entity(
}
}
// If we hit a cube, clear all selections and select this one
if let Some((hit_entity_id, _)) = closest_hit {
// Clear all previous selections and locks
for (networked, _, mut selection) in cube_query.iter_mut() {
selection.clear();
lock_registry.release(networked.network_id, node_id);
}
// Select and lock the clicked cube
for (networked, _, mut selection) in cube_query.iter_mut() {
if networked.network_id == hit_entity_id {
selection.add(hit_entity_id);
let _ = lock_registry.try_acquire(hit_entity_id, node_id);
info!("Selected cube {}", hit_entity_id);
break;
}
}
} else {
// Clicked on empty space - deselect all
for (networked, _, mut selection) in cube_query.iter_mut() {
selection.clear();
lock_registry.release(networked.network_id, node_id);
}
info!("Deselected all cubes");
}
closest_hit.map(|(entity_id, _)| entity_id)
}
/// Apply MoveEntity action to locked cubes
@@ -169,12 +162,12 @@ fn apply_move_entity(
delta: glam::Vec2,
lock_registry: &EntityLockRegistry,
node_id: uuid::Uuid,
cube_query: &mut Query<(&NetworkedEntity, &mut Transform, &mut NetworkedSelection), With<crate::cube::CubeMarker>>,
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() {
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
@@ -187,12 +180,12 @@ fn apply_rotate_entity(
delta: glam::Vec2,
lock_registry: &EntityLockRegistry,
node_id: uuid::Uuid,
cube_query: &mut Query<(&NetworkedEntity, &mut Transform, &mut NetworkedSelection), With<crate::cube::CubeMarker>>,
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() {
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);
@@ -206,11 +199,11 @@ fn apply_move_depth(
delta: f32,
lock_registry: &EntityLockRegistry,
node_id: uuid::Uuid,
cube_query: &mut Query<(&NetworkedEntity, &mut Transform, &mut NetworkedSelection), With<crate::cube::CubeMarker>>,
cube_query: &mut Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
) {
let sensitivity = 0.1;
for (networked, mut transform, _) in cube_query.iter_mut() {
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;
}
@@ -221,9 +214,9 @@ fn apply_move_depth(
fn apply_reset_entity(
lock_registry: &EntityLockRegistry,
node_id: uuid::Uuid,
cube_query: &mut Query<(&NetworkedEntity, &mut Transform, &mut NetworkedSelection), With<crate::cube::CubeMarker>>,
cube_query: &mut Query<(&NetworkedEntity, &mut Transform), With<crate::cube::CubeMarker>>,
) {
for (networked, mut transform, _) in cube_query.iter_mut() {
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;
@@ -317,3 +310,38 @@ fn ray_aabb_intersection(
Some(tmin)
}
}
/// System to update visual appearance based on lock state
///
/// Color scheme:
/// - Green: Locked by us (we can edit)
/// - Blue: 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 - blue
Color::srgb(0.3, 0.5, 0.9)
} 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;
}
}
}

View File

@@ -9,6 +9,7 @@ pub mod debug_ui;
pub mod engine_bridge;
pub mod input;
pub mod rendering;
pub mod session_ui;
pub mod setup;
pub use cube::CubeMarker;

View File

@@ -28,6 +28,22 @@ struct Args {
/// Path to the control socket (Unix domain socket)
#[arg(long, default_value = "/tmp/marathon-control.sock")]
control_socket: String,
/// Log level (trace, debug, info, warn, error)
#[arg(long, default_value = "info")]
log_level: String,
/// Path to log file (relative to current directory)
#[arg(long, default_value = "marathon.log")]
log_file: String,
/// Disable log file output (console only)
#[arg(long, default_value = "false")]
no_log_file: bool,
/// Disable console output (file only)
#[arg(long, default_value = "false")]
no_console: bool,
}
mod camera;
@@ -36,7 +52,6 @@ mod cube;
mod debug_ui;
mod engine_bridge;
mod rendering;
mod selection;
mod session;
mod session_ui;
mod setup;
@@ -49,7 +64,6 @@ mod input;
use camera::*;
use cube::*;
use rendering::*;
use selection::*;
use session::*;
use session_ui::*;
@@ -84,13 +98,86 @@ fn main() {
#[cfg(not(target_os = "ios"))]
{
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("wgpu=error".parse().unwrap())
.add_directive("naga=warn".parse().unwrap()),
)
.init();
use tracing_subscriber::prelude::*;
// Parse log level from args
let default_level = args.log_level.parse::<tracing::Level>()
.unwrap_or_else(|_| {
eprintln!("Invalid log level '{}', using 'info'", args.log_level);
tracing::Level::INFO
});
// Build filter with default level and quieter dependencies
let filter = tracing_subscriber::EnvFilter::from_default_env()
.add_directive(default_level.into())
.add_directive("wgpu=error".parse().unwrap())
.add_directive("naga=warn".parse().unwrap());
// Build subscriber based on combination of flags
match (args.no_console, args.no_log_file) {
(false, false) => {
// Both console and file
let console_layer = tracing_subscriber::fmt::layer()
.with_writer(std::io::stdout);
let log_path = std::path::PathBuf::from(&args.log_file);
let log_dir = log_path.parent().unwrap_or_else(|| std::path::Path::new("."));
let log_filename = log_path.file_name().unwrap().to_str().unwrap();
let file_appender = tracing_appender::rolling::never(log_dir, log_filename);
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
std::mem::forget(_guard);
let file_layer = tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_ansi(false);
tracing_subscriber::registry()
.with(filter)
.with(console_layer)
.with(file_layer)
.init();
eprintln!(">>> Logs written to: {} and console", args.log_file);
}
(false, true) => {
// Console only
let console_layer = tracing_subscriber::fmt::layer()
.with_writer(std::io::stdout);
tracing_subscriber::registry()
.with(filter)
.with(console_layer)
.init();
eprintln!(">>> Console logging only (no log file)");
}
(true, false) => {
// File only
let log_path = std::path::PathBuf::from(&args.log_file);
let log_dir = log_path.parent().unwrap_or_else(|| std::path::Path::new("."));
let log_filename = log_path.file_name().unwrap().to_str().unwrap();
let file_appender = tracing_appender::rolling::never(log_dir, log_filename);
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
std::mem::forget(_guard);
let file_layer = tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_ansi(false);
tracing_subscriber::registry()
.with(filter)
.with(file_layer)
.init();
eprintln!(">>> Logs written to: {} (console disabled)", args.log_file);
}
(true, true) => {
// Neither - warn but initialize anyway
tracing_subscriber::registry()
.with(filter)
.init();
eprintln!(">>> Warning: Both console and file logging disabled!");
}
}
}
eprintln!(">>> Tracing subscriber initialized");
@@ -213,7 +300,7 @@ fn main() {
app.add_plugins(CameraPlugin);
app.add_plugins(RenderingPlugin);
app.add_plugins(input::InputHandlerPlugin);
app.add_plugins(SelectionPlugin);
// SelectionPlugin removed - InputHandlerPlugin already handles selection via GameActions
app.add_plugins(DebugUiPlugin);
app.add_plugins(SessionUiPlugin);
}

View File

@@ -6,7 +6,7 @@
use bevy::prelude::*;
use libmarathon::{
debug_ui::{egui, EguiContexts, EguiPrimaryContextPass},
engine::{EngineBridge, EngineCommand},
engine::{EngineBridge, EngineCommand, NetworkingInitStatus},
networking::{CurrentSession, NodeVectorClock, SessionId, SessionState},
};
@@ -15,10 +15,16 @@ pub struct SessionUiPlugin;
impl Plugin for SessionUiPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SessionUiState>()
.init_resource::<NetworkingStatus>()
.add_systems(EguiPrimaryContextPass, session_ui_panel);
}
}
#[derive(Resource, Default)]
pub struct NetworkingStatus {
pub latest_status: Option<NetworkingInitStatus>,
}
#[derive(Resource, Default)]
struct SessionUiState {
join_code_input: String,
@@ -31,6 +37,7 @@ fn session_ui_panel(
current_session: Res<CurrentSession>,
node_clock: Option<Res<NodeVectorClock>>,
bridge: Res<EngineBridge>,
networking_status: Res<NetworkingStatus>,
) {
// Log session state for debugging
debug!("Session UI: state={:?}, id={}",
@@ -45,65 +52,107 @@ fn session_ui_panel(
.default_pos([320.0, 10.0])
.default_width(280.0)
.show(ctx, |ui| {
// Check if networking is active based on session state
if current_session.session.state == SessionState::Active {
// ONLINE MODE: Networking is active
ui.heading("Session (Online)");
ui.separator();
// Display UI based on session state
match current_session.session.state {
SessionState::Active => {
// ONLINE MODE: Networking is active
ui.heading("Session (Online)");
ui.separator();
ui.horizontal(|ui| {
ui.label("Code:");
ui.code(current_session.session.id.to_code());
if ui.small_button("📋").clicked() {
// TODO: Copy to clipboard (requires clipboard API)
info!("Session code: {}", current_session.session.id.to_code());
}
});
ui.label(format!("State: {:?}", current_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: Networking not started or disconnected
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.horizontal(|ui| {
ui.label("Code:");
ui.code(current_session.session.id.to_code());
if ui.small_button("📋").clicked() {
// TODO: Copy to clipboard (requires clipboard API)
info!("Session code: {}", current_session.session.id.to_code());
}
});
ui.label(format!("State: {:?}", current_session.session.state));
if let Some(clock) = node_clock.as_ref() {
ui.label(format!("Connected nodes: {}", clock.clock.node_count()));
}
ui.add_space(10.0);
// Stop networking button
if ui.button("🔌 Stop Networking").clicked() {
info!("Stopping networking");
bridge.send_command(EngineCommand::StopNetworking);
}
}
SessionState::Joining => {
// INITIALIZING: Networking is starting up
ui.heading("Connecting...");
ui.separator();
ui.add_space(5.0);
// Display initialization status
if let Some(ref status) = networking_status.latest_status {
match status {
NetworkingInitStatus::CreatingEndpoint => {
ui.label("⏳ Creating network endpoint...");
}
NetworkingInitStatus::EndpointReady => {
ui.label("✓ Network endpoint ready");
}
NetworkingInitStatus::DiscoveringPeers { session_code, attempt } => {
ui.label(format!("🔍 Discovering peers for session {}", session_code));
ui.label(format!(" Attempt {}/3...", attempt));
}
NetworkingInitStatus::PeersFound { count } => {
ui.label(format!("✓ Found {} peer(s)!", count));
}
NetworkingInitStatus::NoPeersFound => {
ui.label(" No existing peers found");
ui.label(" (Creating new session)");
}
NetworkingInitStatus::PublishingToDHT => {
ui.label("📡 Publishing to DHT...");
}
NetworkingInitStatus::InitializingGossip => {
ui.label("🔧 Initializing gossip protocol...");
}
}
} else {
ui.label("⏳ Initializing...");
}
// Join existing session button
if ui.button(" Join Session").clicked() {
ui_state.show_join_dialog = true;
ui.add_space(10.0);
ui.label("Please wait...");
}
_ => {
// OFFLINE MODE: Networking not started or disconnected
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.timestamps.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;
}
}
}
});
@@ -114,7 +163,10 @@ fn session_ui_panel(
.collapsible(false)
.show(ctx, |ui| {
ui.label("Enter session code (abc-def-123):");
ui.text_edit_singleline(&mut ui_state.join_code_input);
let text_edit = ui.text_edit_singleline(&mut ui_state.join_code_input);
// Auto-focus the text input when dialog opens
text_edit.request_focus();
ui.add_space(5.0);
ui.label("Note: Joining requires app restart");