From e1d2253af5ddef8d539ba84e81bc4c7a24584c4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Thu, 17 Sep 2026 15:04:39 +0000 Subject: [PATCH] Implement direct P2P session scaffolding (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #24 Co-authored-by: Álex Román Núñez --- README.md | 23 ++-- launch_localtest.sh | 8 ++ src/args.rs | 45 ++++++++ src/lib.rs | 1 + src/match_loading.rs | 51 +++++++-- src/netcode/session.rs | 235 ++++++++++++++++++++++++++++++++++++----- 6 files changed, 321 insertions(+), 42 deletions(-) create mode 100755 launch_localtest.sh diff --git a/README.md b/README.md index c4e3806..8deb6b7 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,15 @@ -# Game Test +# Shinespark -A Bevy game, editable in the [jackdaw](https://github.com/jbuehler23/jackdaw) editor. +Fancy heavily advanced platform fighting game -- `cargo run` plays the game standalone; no editor required. -- Open the folder in jackdaw to author scenes, then hit Play. The - editor builds its own artifacts into `.jackdaw/` (gitignored) and - never modifies your manifest or lockfile. -- Gameplay lives in `src/lib.rs` (`GamePlugin`); authored scenes live - in `assets/*.bsn`. -- Components you derive `Reflect` on appear in the editor's inspector - automatically; no registration code is needed. +## Direct P2P Debugging + +```text +# Process 1 owns player 0 and listens on 7000. +cargo run -- --debug-match --players 2 --local-port 7000 \ + --net-player 0=local --net-player 1=127.0.0.1:7001 + +# Process 2 owns player 1 and listens on 7001. +cargo run -- --debug-match --players 2 --local-port 7001 \ + --net-player 0=127.0.0.1:7000 --net-player 1=local +``` diff --git a/launch_localtest.sh b/launch_localtest.sh new file mode 100755 index 0000000..8f03849 --- /dev/null +++ b/launch_localtest.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +FIRST="cargo run -- --debug-match --players 2 --local-port 7000 \ + --net-player 0=local --net-player 1=127.0.0.1:7001" +SECOND="cargo run -- --debug-match --players 2 --local-port 7001 \ +--net-player 0=127.0.0.1:7000 --net-player 1=local" + +(trap 'kill 0' SIGINT; $FIRST & $SECOND) \ No newline at end of file diff --git a/src/args.rs b/src/args.rs index ef93291..802abe3 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,5 +1,6 @@ use bevy::prelude::*; use clap::Parser; +use std::net::SocketAddr; use std::path::PathBuf; #[derive(Parser, Debug, Clone, Resource)] @@ -15,6 +16,12 @@ pub struct Args { pub check_distance: usize, #[clap(long, default_value_t = 1)] pub players: usize, + /// UDP port to bind for a direct peer-to-peer session. + #[clap(long, default_value_t = 6969)] + pub local_port: u16, + /// Player ownership declarations in the form HANDLE=local or HANDLE=ADDRESS. + #[clap(long = "net-player", value_name = "HANDLE=local|ADDRESS")] + pub net_players: Vec, /// Record a replay to this path. #[clap(long, conflicts_with = "play_replay")] pub record_replay: Option, @@ -25,3 +32,41 @@ pub struct Args { #[clap(long, value_enum, default_value_t = crate::replay::ReplayFormat::Binary)] pub replay_format: crate::replay::ReplayFormat, } + +pub fn parse_socket_addr(value: &str) -> Result { + let address = value + .parse::() + .map_err(|_| format!("invalid socket address: {value}"))?; + if address.port() == 0 { + return Err(format!("socket address must use a nonzero port: {value}")); + } + Ok(address) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_network_options() { + let args = Args::try_parse_from([ + "game", + "--local-port", + "7000", + "--net-player", + "0=local", + "--net-player", + "1=127.0.0.1:7001", + ]) + .unwrap(); + + assert_eq!(args.local_port, 7000); + assert_eq!(args.net_players, ["0=local", "1=127.0.0.1:7001"]); + } + + #[test] + fn rejects_invalid_socket_addresses() { + assert!(parse_socket_addr("127.0.0.1:0").is_err()); + assert!(parse_socket_addr("not-an-address").is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index fc80c25..077b068 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -71,6 +71,7 @@ pub enum AppState { LoadingMatch, PreparingMatch, InMatch, + MatchDisconnected, MatchLoadFailed, } diff --git a/src/match_loading.rs b/src/match_loading.rs index 1ba5881..6dda130 100644 --- a/src/match_loading.rs +++ b/src/match_loading.rs @@ -8,25 +8,28 @@ use bevy_ggrs::prelude::Session; use serde::{Deserialize, Serialize}; use crate::{ - AppState, args::Args, fighter::{ - FighterId, FighterRoster, animation::AnimKind, attack::{AttackKind, FighterAttackScriptAssets}, - baked_animation::{BAKED_ANIMATION_FPS, BakedFighterAnimations}, + baked_animation::{BakedFighterAnimations, BAKED_ANIMATION_FPS}, manifest::{FighterManifest, FighterManifestRegistry}, spawn_fighter, visual::FighterAnimations, + FighterId, FighterRoster, }, input::{LocalInputAssignments, LocalInputSource}, math::{int::FGi32, vec::FGVec2}, - netcode::{GGRSCfg, session::create_session}, + netcode::{ + session::{create_session, ConnectionStatus, SessionConfig}, + GGRSCfg, + }, scripting::FighterAttackScript, stage::{ self, manifest::{StageId, StageManifest, StageManifestRegistry}, }, + AppState, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -140,6 +143,7 @@ pub fn initiate_match( next_state: &mut NextState, request: PendingMatch, ) { + commands.remove_resource::(); commands.insert_resource(request); next_state.set(AppState::LoadingMatch); } @@ -176,6 +180,14 @@ pub fn initiate_default_match( } return; } + let session_config = match SessionConfig::from_args(&args, args.players) { + Ok(config) => config, + Err(error) => { + error!("Invalid network configuration: {error}"); + next_state.set(AppState::MatchLoadFailed); + return; + } + }; let players = (0..args.players) .map(|handle| MatchPlayer { handle, @@ -183,10 +195,21 @@ pub fn initiate_default_match( }) .collect(); - assigments.push((0, LocalInputSource::Keyboard)); + assigments.clear(); + let local_handles = session_config + .players + .iter() + .enumerate() + .filter_map(|(handle, player)| { + matches!(player, crate::netcode::session::PlayerConnection::Local).then_some(handle) + }); + let mut local_handles = local_handles; + if let Some(handle) = local_handles.next() { + assigments.push((handle, LocalInputSource::Keyboard)); + } - for (idx, entity) in gamepads.iter().take(args.players - 1).enumerate() { - assigments.push((idx + 1, LocalInputSource::Gamepad(entity))); + for (entity, handle) in gamepads.iter().zip(local_handles) { + assigments.push((handle, LocalInputSource::Gamepad(entity))); } initiate_match( @@ -373,6 +396,16 @@ pub fn prepare_match( )); } + let session_config = match SessionConfig::from_args(&args, request.players.len()) { + Ok(config) => config, + Err(error) => { + fail_match( + &mut next_state, + &format!("invalid network configuration: {error}"), + ); + return; + } + }; let session = match create_session(&args, request.players.len()) { Ok(session) => session, Err(error) => { @@ -418,6 +451,8 @@ pub fn prepare_match( } commands.insert_resource(FighterRoster(fighter_roster)); + commands.insert_resource(session_config); + commands.insert_resource(ConnectionStatus::default()); commands.insert_resource::>(session); next_state.set(AppState::InMatch); } @@ -448,6 +483,8 @@ pub fn cleanup_match( commands.entity(fighter).despawn(); } commands.remove_resource::>(); + commands.remove_resource::(); + commands.remove_resource::(); commands.remove_resource::(); commands.remove_resource::(); commands.remove_resource::(); diff --git a/src/netcode/session.rs b/src/netcode/session.rs index e85e15f..7193aba 100644 --- a/src/netcode/session.rs +++ b/src/netcode/session.rs @@ -1,49 +1,234 @@ -//! Bringing up the GGRS session and reporting what it does. +//! 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::Args; +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); // (optional) set input delay for the local player + .with_input_delay(args.input_delay); - for i in 0..player_count { - sess_build = sess_build.add_player(PlayerType::Local, i)?; + 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 { - println!("Start synctest!"); - let sess = sess_build.start_synctest_session()?; - Ok(Session::SyncTest(sess)) + info!("starting GGRS sync test"); + Ok(Session::SyncTest(sess_build.start_synctest_session()?)) } else { - println!("Start p2p!"); - let socket = UdpNonBlockingSocket::bind_to_port(6969)?; - let sess = sess_build.start_p2p_session(socket)?; - Ok(Session::P2P(sess)) + 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)?)) } } -pub fn print_events_system(session: Option>>) { - let Some(mut session) = session else { +#[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; }; - match session.as_mut() { - Session::P2P(s) => { - for event in s.events() { - match event { - GgrsEvent::Disconnected { .. } | GgrsEvent::NetworkInterrupted { .. } => { - warn!("GGRS event: {event:?}") - } - GgrsEvent::DesyncDetected { .. } => error!("GGRS event: {event:?}"), - _ => info!("GGRS event: {event:?}"), - } + 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) + )); } }