Add spawn/delete commands, fix session state and entity broadcast

- marathonctl now supports spawn/delete entity commands
- Fixed session state bug (was transitioning to Left every 5s)
- Fixed entity broadcast to detect Added<NetworkedEntity>
- Added AppCommandQueue pattern for app-level control commands

References: #131, #132
This commit is contained in:
2025-12-24 12:53:50 +00:00
parent a0c13be6d6
commit 8ca02fd492
12 changed files with 1736 additions and 22 deletions

View File

@@ -0,0 +1,170 @@
//! Control socket protocol for remote engine control
//!
//! This module defines the message protocol for controlling the engine via
//! Unix domain sockets without exposing network ports. Used for testing,
//! validation, and programmatic control of sessions.
//!
//! # Security
//!
//! Currently debug-only. See issue #135 for production security requirements.
use uuid::Uuid;
use crate::networking::{
SessionId,
SessionState,
SyncMessage,
VersionedMessage,
};
/// Control command sent to the engine
#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub enum ControlCommand {
/// Get current session status
GetStatus,
/// Send a test message through gossip
SendTestMessage { content: String },
/// Inject a message directly into the incoming queue (for testing)
InjectMessage { message: VersionedMessage },
/// Broadcast a full sync message through gossip
BroadcastMessage { message: SyncMessage },
/// Request graceful shutdown
Shutdown,
// Session lifecycle commands
/// Join a specific session by code
JoinSession { session_code: String },
/// Leave the current session gracefully
LeaveSession,
/// Get detailed current session information
GetSessionInfo,
/// List all sessions in the database
ListSessions,
/// Delete a session from the database
DeleteSession { session_code: String },
/// Get list of connected peers in current session
ListPeers,
// Entity commands
/// Spawn an entity with a given type and position
SpawnEntity {
entity_type: String,
position: [f32; 3],
},
/// Delete an entity by its UUID
DeleteEntity { entity_id: Uuid },
}
/// Detailed session information
#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub struct SessionInfo {
pub session_id: SessionId,
pub session_name: Option<String>,
pub state: SessionState,
pub created_at: i64,
pub last_active: i64,
pub entity_count: usize,
}
/// Peer information
#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub struct PeerInfo {
pub node_id: Uuid,
pub connected_since: Option<i64>,
}
/// Response from the engine to a control command
#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub enum ControlResponse {
/// Session status information
Status {
node_id: Uuid,
session_id: SessionId,
outgoing_queue_size: usize,
incoming_queue_size: usize,
/// Number of connected peers (if available from gossip)
connected_peers: Option<usize>,
},
/// Detailed session information
SessionInfo(SessionInfo),
/// List of sessions
Sessions(Vec<SessionInfo>),
/// List of connected peers
Peers(Vec<PeerInfo>),
/// Acknowledgment of command execution
Ok { message: String },
/// Error occurred during command execution
Error { error: String },
}
impl ControlCommand {
/// Serialize a command to bytes using rkyv
pub fn to_bytes(&self) -> Result<Vec<u8>, rkyv::rancor::Error> {
rkyv::to_bytes::<rkyv::rancor::Error>(self).map(|b| b.to_vec())
}
/// Deserialize a command from bytes using rkyv
pub fn from_bytes(bytes: &[u8]) -> Result<Self, rkyv::rancor::Error> {
rkyv::from_bytes::<Self, rkyv::rancor::Error>(bytes)
}
}
impl ControlResponse {
/// Serialize a response to bytes using rkyv
pub fn to_bytes(&self) -> Result<Vec<u8>, rkyv::rancor::Error> {
rkyv::to_bytes::<rkyv::rancor::Error>(self).map(|b| b.to_vec())
}
/// Deserialize a response from bytes using rkyv
pub fn from_bytes(bytes: &[u8]) -> Result<Self, rkyv::rancor::Error> {
rkyv::from_bytes::<Self, rkyv::rancor::Error>(bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_command_roundtrip() {
let cmd = ControlCommand::GetStatus;
let bytes = cmd.to_bytes().unwrap();
let decoded = ControlCommand::from_bytes(&bytes).unwrap();
match decoded {
| ControlCommand::GetStatus => {},
| _ => panic!("Failed to decode GetStatus"),
}
}
#[test]
fn test_response_roundtrip() {
let resp = ControlResponse::Ok {
message: "Test".to_string(),
};
let bytes = resp.to_bytes().unwrap();
let decoded = ControlResponse::from_bytes(&bytes).unwrap();
match decoded {
| ControlResponse::Ok { message } => assert_eq!(message, "Test"),
| _ => panic!("Failed to decode Ok response"),
}
}
}

View File

@@ -52,7 +52,7 @@ impl NodeVectorClock {
/// System to generate and broadcast EntityDelta messages
///
/// This system:
/// 1. Queries for Changed<NetworkedEntity>
/// 1. Queries for Added<NetworkedEntity> or Changed<NetworkedEntity>
/// 2. Serializes all components on those entities
/// 3. Builds EntityDelta messages
/// 4. Broadcasts via GossipBridge
@@ -73,7 +73,7 @@ pub fn generate_delta_system(world: &mut World) {
let changed_entities: Vec<(Entity, uuid::Uuid, uuid::Uuid)> = {
let mut query =
world.query_filtered::<(Entity, &NetworkedEntity), Changed<NetworkedEntity>>();
world.query_filtered::<(Entity, &NetworkedEntity), Or<(Added<NetworkedEntity>, Changed<NetworkedEntity>)>>();
query
.iter(world)
.map(|(entity, networked)| (entity, networked.network_id, networked.owner_node_id))

View File

@@ -36,6 +36,7 @@ mod auth;
mod blob_support;
mod change_detection;
mod components;
mod control;
mod delta_generation;
mod entity_map;
mod error;
@@ -62,6 +63,7 @@ pub use auth::*;
pub use blob_support::*;
pub use change_detection::*;
pub use components::*;
pub use control::*;
pub use delta_generation::*;
pub use entity_map::*;
pub use error::*;

View File

@@ -168,7 +168,8 @@ pub fn save_session_on_shutdown_system(world: &mut World) {
// Update session metadata
session.touch();
session.transition_to(SessionState::Left);
// Note: We don't transition to Left here - that only happens on actual shutdown
// This periodic save just persists the current state
// Count entities in the world
let entity_count = world