From 566990b7afd0f2f6c1aa6bcddcbb292912d2be58 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 14:29:41 +0900 Subject: [PATCH] feat(net): add UDP server/client with connection management Adds NetSocket (non-blocking UdpSocket wrapper with local_addr), NetServer (connection tracking via HashMap, poll/broadcast/send_to_client), and NetClient (connect/poll/send/disconnect lifecycle). Includes an integration test on 127.0.0.1:0 that validates ClientConnected, Connected, and UserData receipt end-to-end with 50ms sleeps to ensure UDP packet delivery. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 26 +++++ crates/voltex_net/src/client.rs | 96 ++++++++++++++++ crates/voltex_net/src/server.rs | 195 ++++++++++++++++++++++++++++++++ crates/voltex_net/src/socket.rs | 58 ++++++++++ 4 files changed, 375 insertions(+) create mode 100644 crates/voltex_net/src/client.rs create mode 100644 crates/voltex_net/src/server.rs create mode 100644 crates/voltex_net/src/socket.rs diff --git a/Cargo.lock b/Cargo.lock index 4031fe7..b0a6ba8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -432,6 +432,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "deferred_demo" +version = "0.1.0" +dependencies = [ + "bytemuck", + "env_logger", + "log", + "pollster", + "voltex_math", + "voltex_platform", + "voltex_renderer", + "wgpu", + "winit", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -2029,6 +2044,13 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "voltex_ai" +version = "0.1.0" +dependencies = [ + "voltex_math", +] + [[package]] name = "voltex_asset" version = "0.1.0" @@ -2051,6 +2073,10 @@ dependencies = [ name = "voltex_math" version = "0.1.0" +[[package]] +name = "voltex_net" +version = "0.1.0" + [[package]] name = "voltex_physics" version = "0.1.0" diff --git a/crates/voltex_net/src/client.rs b/crates/voltex_net/src/client.rs new file mode 100644 index 0000000..e8103e0 --- /dev/null +++ b/crates/voltex_net/src/client.rs @@ -0,0 +1,96 @@ +use std::net::SocketAddr; + +use crate::packet::Packet; +use crate::socket::NetSocket; + +/// Events produced by the client during polling. +pub enum ClientEvent { + Connected { client_id: u32 }, + Disconnected, + PacketReceived { packet: Packet }, +} + +/// A non-blocking UDP client. +pub struct NetClient { + socket: NetSocket, + server_addr: SocketAddr, + client_id: Option, + name: String, +} + +impl NetClient { + /// Create and bind a new client socket. + /// + /// - `local_addr`: local bind address (e.g. "127.0.0.1:0") + /// - `server_addr`: the server's `SocketAddr` + /// - `name`: client display name used in Connect packet + pub fn new(local_addr: &str, server_addr: SocketAddr, name: &str) -> Result { + let socket = NetSocket::bind(local_addr)?; + Ok(NetClient { + socket, + server_addr, + client_id: None, + name: name.to_string(), + }) + } + + /// Send a Connect packet to the server. + pub fn connect(&self) -> Result<(), String> { + let packet = Packet::Connect { + client_name: self.name.clone(), + }; + self.socket.send_to(&packet, self.server_addr) + } + + /// Poll for incoming packets and return any resulting client events. + pub fn poll(&mut self) -> Vec { + let mut events = Vec::new(); + + while let Some((packet, _addr)) = self.socket.recv_from() { + match &packet { + Packet::Accept { client_id } => { + self.client_id = Some(*client_id); + events.push(ClientEvent::Connected { + client_id: *client_id, + }); + } + Packet::Disconnect { .. } => { + self.client_id = None; + events.push(ClientEvent::Disconnected); + } + _ => { + events.push(ClientEvent::PacketReceived { + packet: packet.clone(), + }); + } + } + } + + events + } + + /// Send an arbitrary packet to the server. + pub fn send(&self, packet: Packet) -> Result<(), String> { + self.socket.send_to(&packet, self.server_addr) + } + + /// Returns true if the client has received an Accept from the server. + pub fn is_connected(&self) -> bool { + self.client_id.is_some() + } + + /// Returns the client id assigned by the server, or None if not yet connected. + pub fn client_id(&self) -> Option { + self.client_id + } + + /// Send a Disconnect packet to the server and clear local state. + pub fn disconnect(&mut self) -> Result<(), String> { + if let Some(id) = self.client_id { + let packet = Packet::Disconnect { client_id: id }; + self.socket.send_to(&packet, self.server_addr)?; + self.client_id = None; + } + Ok(()) + } +} diff --git a/crates/voltex_net/src/server.rs b/crates/voltex_net/src/server.rs new file mode 100644 index 0000000..631376e --- /dev/null +++ b/crates/voltex_net/src/server.rs @@ -0,0 +1,195 @@ +use std::collections::HashMap; +use std::net::SocketAddr; + +use crate::packet::Packet; +use crate::socket::NetSocket; + +/// Information about a connected client. +pub struct ClientInfo { + pub id: u32, + pub addr: SocketAddr, + pub name: String, +} + +/// Events produced by the server during polling. +pub enum ServerEvent { + ClientConnected { client_id: u32, name: String }, + ClientDisconnected { client_id: u32 }, + PacketReceived { client_id: u32, packet: Packet }, +} + +/// A non-blocking UDP server that manages multiple clients. +pub struct NetServer { + socket: NetSocket, + clients: HashMap, + addr_to_id: HashMap, + next_id: u32, +} + +impl NetServer { + /// Bind the server to the given address. + pub fn new(addr: &str) -> Result { + let socket = NetSocket::bind(addr)?; + Ok(NetServer { + socket, + clients: HashMap::new(), + addr_to_id: HashMap::new(), + next_id: 1, + }) + } + + /// Return the local address the server is listening on. + pub fn local_addr(&self) -> SocketAddr { + self.socket.local_addr() + } + + /// Poll for incoming packets and return any resulting server events. + pub fn poll(&mut self) -> Vec { + let mut events = Vec::new(); + + while let Some((packet, addr)) = self.socket.recv_from() { + match &packet { + Packet::Connect { client_name } => { + // Assign a new id and send Accept + let id = self.next_id; + self.next_id += 1; + let name = client_name.clone(); + + let info = ClientInfo { + id, + addr, + name: name.clone(), + }; + self.clients.insert(id, info); + self.addr_to_id.insert(addr, id); + + let accept = Packet::Accept { client_id: id }; + if let Err(e) = self.socket.send_to(&accept, addr) { + eprintln!("[NetServer] Failed to send Accept to {}: {}", addr, e); + } + + events.push(ServerEvent::ClientConnected { client_id: id, name }); + } + Packet::Disconnect { client_id } => { + let id = *client_id; + if let Some(info) = self.clients.remove(&id) { + self.addr_to_id.remove(&info.addr); + events.push(ServerEvent::ClientDisconnected { client_id: id }); + } + } + _ => { + // Map address to client id + if let Some(&client_id) = self.addr_to_id.get(&addr) { + events.push(ServerEvent::PacketReceived { + client_id, + packet: packet.clone(), + }); + } else { + eprintln!("[NetServer] Packet from unknown addr {}", addr); + } + } + } + } + + events + } + + /// Send a packet to every connected client. + pub fn broadcast(&self, packet: &Packet) { + for info in self.clients.values() { + if let Err(e) = self.socket.send_to(packet, info.addr) { + eprintln!("[NetServer] broadcast failed for client {}: {}", info.id, e); + } + } + } + + /// Send a packet to a specific client by id. + pub fn send_to_client(&self, id: u32, packet: &Packet) { + if let Some(info) = self.clients.get(&id) { + if let Err(e) = self.socket.send_to(packet, info.addr) { + eprintln!("[NetServer] send_to_client {} failed: {}", id, e); + } + } + } + + /// Returns a slice of all connected clients. + pub fn clients(&self) -> impl Iterator { + self.clients.values() + } + + /// Returns the number of connected clients. + pub fn client_count(&self) -> usize { + self.clients.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::{ClientEvent, NetClient}; + use std::time::Duration; + + #[test] + fn test_integration_connect_and_userdata() { + // Step 1: Start server on OS-assigned port + let mut server = NetServer::new("127.0.0.1:0").expect("server bind failed"); + let server_addr = server.local_addr(); + + // Step 2: Create client on OS-assigned port, point at server + let mut client = NetClient::new("127.0.0.1:0", server_addr, "TestClient") + .expect("client bind failed"); + + // Step 3: Client sends Connect + client.connect().expect("connect send failed"); + + // Step 4: Give the packet time to travel + std::thread::sleep(Duration::from_millis(50)); + + // Step 5: Server poll → should get ClientConnected + let server_events = server.poll(); + let mut connected_id = None; + for event in &server_events { + if let ServerEvent::ClientConnected { client_id, name } = event { + connected_id = Some(*client_id); + assert_eq!(name, "TestClient"); + } + } + assert!(connected_id.is_some(), "Server did not receive ClientConnected"); + + // Step 6: Client poll → should get Connected + std::thread::sleep(Duration::from_millis(50)); + let client_events = client.poll(); + let mut got_connected = false; + for event in &client_events { + if let ClientEvent::Connected { client_id } = event { + assert_eq!(Some(*client_id), connected_id); + got_connected = true; + } + } + assert!(got_connected, "Client did not receive Connected event"); + + // Step 7: Client sends UserData, server should receive it + let cid = client.client_id().unwrap(); + let user_packet = Packet::UserData { + client_id: cid, + data: vec![1, 2, 3, 4], + }; + client.send(user_packet.clone()).expect("send userdata failed"); + + std::thread::sleep(Duration::from_millis(50)); + + let server_events2 = server.poll(); + let mut got_packet = false; + for event in server_events2 { + if let ServerEvent::PacketReceived { client_id, packet } = event { + assert_eq!(client_id, cid); + assert_eq!(packet, user_packet); + got_packet = true; + } + } + assert!(got_packet, "Server did not receive UserData packet"); + + // Cleanup: disconnect + client.disconnect().expect("disconnect send failed"); + } +} diff --git a/crates/voltex_net/src/socket.rs b/crates/voltex_net/src/socket.rs new file mode 100644 index 0000000..668aca0 --- /dev/null +++ b/crates/voltex_net/src/socket.rs @@ -0,0 +1,58 @@ +use std::net::{SocketAddr, UdpSocket}; + +use crate::Packet; + +/// Maximum UDP datagram size we'll allocate for receiving. +const MAX_PACKET_SIZE: usize = 65535; + +/// A non-blocking UDP socket wrapper. +pub struct NetSocket { + inner: UdpSocket, +} + +impl NetSocket { + /// Bind a new non-blocking UDP socket to the given address. + pub fn bind(addr: &str) -> Result { + let socket = UdpSocket::bind(addr) + .map_err(|e| format!("UdpSocket::bind({}) failed: {}", addr, e))?; + socket + .set_nonblocking(true) + .map_err(|e| format!("set_nonblocking failed: {}", e))?; + Ok(NetSocket { inner: socket }) + } + + /// Returns the local address this socket is bound to. + pub fn local_addr(&self) -> SocketAddr { + self.inner.local_addr().expect("local_addr unavailable") + } + + /// Serialize and send a packet to the given address. + pub fn send_to(&self, packet: &Packet, addr: SocketAddr) -> Result<(), String> { + let bytes = packet.to_bytes(); + self.inner + .send_to(&bytes, addr) + .map_err(|e| format!("send_to failed: {}", e))?; + Ok(()) + } + + /// Try to receive one packet. Returns None on WouldBlock (no data available). + pub fn recv_from(&self) -> Option<(Packet, SocketAddr)> { + let mut buf = vec![0u8; MAX_PACKET_SIZE]; + match self.inner.recv_from(&mut buf) { + Ok((len, addr)) => { + match Packet::from_bytes(&buf[..len]) { + Ok(packet) => Some((packet, addr)), + Err(e) => { + eprintln!("[NetSocket] Failed to parse packet from {}: {}", addr, e); + None + } + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => None, + Err(e) => { + eprintln!("[NetSocket] recv_from error: {}", e); + None + } + } + } +}