//! Bringing up GGRS sessions and reporting their connection lifecycle. use std::net::SocketAddr; use bevy::prelude::*; use bevy_ggrs::{ggrs::UdpNonBlockingSocket, prelude::*}; use thiserror::Error; use crate::args::{parse_socket_addr, Args}; use crate::netcode::GGRSCfg; #[derive(Clone, Debug, PartialEq, Eq)] pub enum PlayerConnection { Local, Remote(SocketAddr), } #[derive(Clone, Debug, PartialEq, Eq, Resource)] pub struct SessionConfig { pub local_port: u16, pub players: Vec, } #[derive(Debug, Error, PartialEq, Eq)] pub enum SessionConfigError { #[error("a session must have at least one player")] NoPlayers, #[error("local UDP port must be nonzero")] LocalPortZero, #[error("invalid network player declaration `{0}`; expected HANDLE=local or HANDLE=ADDRESS")] InvalidPlayerDeclaration(String), #[error("network player handle {0} is outside the session player range")] PlayerHandleOutOfRange(usize), #[error("network player handle {0} was declared more than once")] DuplicatePlayerHandle(usize), #[error("network player {0} has an invalid address: {1}")] InvalidRemoteAddress(usize, String), #[error("at least one player must be local")] NoLocalPlayers, #[error("network player declarations are not supported in sync-test or replay mode")] UnsupportedOfflineNetworkConfig, } impl SessionConfig { pub fn from_args(args: &Args, player_count: usize) -> Result { if player_count == 0 { return Err(SessionConfigError::NoPlayers); } if args.local_port == 0 { return Err(SessionConfigError::LocalPortZero); } if (args.synctest || args.play_replay.is_some()) && !args.net_players.is_empty() { return Err(SessionConfigError::UnsupportedOfflineNetworkConfig); } let mut players = vec![PlayerConnection::Local; player_count]; for declaration in &args.net_players { let Some((handle, address)) = declaration.split_once('=') else { return Err(SessionConfigError::InvalidPlayerDeclaration( declaration.clone(), )); }; let handle = handle .parse::() .map_err(|_| SessionConfigError::InvalidPlayerDeclaration(declaration.clone()))?; if handle >= player_count { return Err(SessionConfigError::PlayerHandleOutOfRange(handle)); } if !matches!(players[handle], PlayerConnection::Local) || args.net_players[..] .iter() .take_while(|item| *item != declaration) .any(|item| { item.split_once('=') .and_then(|(handle, _)| handle.parse::().ok()) == Some(handle) }) { return Err(SessionConfigError::DuplicatePlayerHandle(handle)); } players[handle] = if address == "local" { PlayerConnection::Local } else { PlayerConnection::Remote( parse_socket_addr(address) .map_err(|error| SessionConfigError::InvalidRemoteAddress(handle, error))?, ) }; } if !players .iter() .any(|player| matches!(player, PlayerConnection::Local)) { return Err(SessionConfigError::NoLocalPlayers); } Ok(Self { local_port: args.local_port, players, }) } } pub fn create_session(args: &Args, player_count: usize) -> Result> { let config = SessionConfig::from_args(args, player_count) .map_err(|error| bevy::ecs::error::BevyError::from(error.to_string()))?; let mut sess_build = SessionBuilder::::new() .with_num_players(player_count)? .with_check_distance(args.check_distance) .with_input_delay(args.input_delay); for (handle, player) in config.players.iter().enumerate() { let player_type = match player { PlayerConnection::Local => PlayerType::Local, PlayerConnection::Remote(address) => PlayerType::Remote(*address), }; sess_build = sess_build.add_player(player_type, handle)?; } if args.synctest { info!("starting GGRS sync test"); Ok(Session::SyncTest(sess_build.start_synctest_session()?)) } else { info!( "starting direct UDP P2P session on port {}", config.local_port ); let socket = UdpNonBlockingSocket::bind_to_port(config.local_port)?; Ok(Session::P2P(sess_build.start_p2p_session(socket)?)) } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Resource)] pub enum ConnectionStatus { #[default] Connecting, Running, Interrupted, } #[derive(Debug, Clone, PartialEq, Eq, Resource)] pub struct MatchDisconnected { pub address: SocketAddr, } pub fn print_events_system( mut commands: Commands, mut next_state: ResMut>, mut status: Option>, session: Option>>, ) { let (Some(mut session), Some(status)) = (session, status.as_deref_mut()) else { return; }; let Session::P2P(session) = session.as_mut() else { return; }; for event in session.events() { match event { GgrsEvent::NetworkInterrupted { addr, .. } => { warn!("GGRS network interrupted: {addr}"); *status = ConnectionStatus::Interrupted; } GgrsEvent::NetworkResumed { addr } => { info!("GGRS network resumed: {addr}"); *status = ConnectionStatus::Running; } GgrsEvent::Disconnected { addr } => { error!("GGRS peer disconnected: {addr}"); commands.insert_resource(MatchDisconnected { address: addr }); next_state.set(crate::AppState::MatchDisconnected); } GgrsEvent::Synchronized { .. } => { info!("GGRS session synchronized"); *status = ConnectionStatus::Running; } GgrsEvent::DesyncDetected { .. } => error!("GGRS desync detected: {event:?}"), _ => info!("GGRS event: {event:?}"), } } } #[cfg(test)] mod tests { use super::*; use clap::Parser; fn args(values: &[&str]) -> Args { Args::try_parse_from(values).unwrap() } #[test] fn defaults_to_local_players() { let config = SessionConfig::from_args(&args(&["game", "--players", "2"]), 2).unwrap(); assert_eq!( config.players, [PlayerConnection::Local, PlayerConnection::Local] ); } #[test] fn configures_remote_players_by_handle() { let config = SessionConfig::from_args( &args(&["game", "--players", "2", "--net-player", "1=127.0.0.1:7001"]), 2, ) .unwrap(); assert!(matches!(config.players[0], PlayerConnection::Local)); assert!(matches!(config.players[1], PlayerConnection::Remote(_))); } #[test] fn validates_handles_and_local_ownership() { let duplicate = args(&[ "game", "--players", "2", "--net-player", "1=127.0.0.1:7001", "--net-player", "1=127.0.0.1:7002", ]); assert!(matches!( SessionConfig::from_args(&duplicate, 2), Err(SessionConfigError::DuplicatePlayerHandle(1)) )); let no_local = args(&["game", "--players", "1", "--net-player", "0=127.0.0.1:7001"]); assert!(matches!( SessionConfig::from_args(&no_local, 1), Err(SessionConfigError::NoLocalPlayers) )); } }