From 386a970b53fcd24c4701300b2a8a02a5b5844ce6 Mon Sep 17 00:00:00 2001 From: core Date: Wed, 19 Nov 2025 10:58:17 -0500 Subject: [PATCH] recursive disconnect --- .cargo/config.toml | 4 +- crates/unified/Cargo.toml | 4 +- crates/unified/src/attachment.rs | 2 +- crates/unified/src/client/mod.rs | 11 +- crates/unified/src/client_plugins.rs | 7 +- crates/unified/src/lib.rs | 43 ---- crates/unified/src/main.rs | 110 +++++++++-- crates/unified/src/server/player.rs | 241 +++++++++++++++++------ crates/unified/src/server/player/join.rs | 110 +++++++++++ crates/unified/src/server_plugins.rs | 14 +- crates/unified/src/shared_plugins.rs | 12 +- rust-toolchain.toml | 2 + 12 files changed, 412 insertions(+), 148 deletions(-) delete mode 100644 crates/unified/src/lib.rs create mode 100644 crates/unified/src/server/player/join.rs create mode 100644 rust-toolchain.toml diff --git a/.cargo/config.toml b/.cargo/config.toml index 04791d0ead1bdf08a9065432949d8a1db89bbeb5..dc06211351d14c6d0c56ae46e567bed8146553ab 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -5,8 +5,8 @@ xtask = "run --release --package xtask --" #rustc-wrapper = "/usr/bin/sccache" [target.x86_64-unknown-linux-gnu] -#linker = "mold" -rustflags = ["-C", "link-arg=-fuse-ld=mold"] +linker = "clang" +rustflags = ["-C", "link-arg=-fuse-ld=mold", "-Zshare-generics=y"] [target.wasm32-unknown-unknown] rustflags = ['--cfg', 'getrandom_backend="wasm_js"'] diff --git a/crates/unified/Cargo.toml b/crates/unified/Cargo.toml index edaa53b8109fd0e4919a23c77743534ef9eca9cd..b6b54f999ffcfad43e0023a05be00dce4c36467f 100644 --- a/crates/unified/Cargo.toml +++ b/crates/unified/Cargo.toml @@ -4,8 +4,8 @@ description = "A game about floating through space" edition = "2024" version = "0.1.0" -[lib] -crate-type = ["cdylib", "rlib"] +#[lib] +#crate-type = ["cdylib", "rlib"] [dependencies] bevy = { version = "0.17", default-features = false, features = [ diff --git a/crates/unified/src/attachment.rs b/crates/unified/src/attachment.rs index 643f491726a8fd19339b45f5d56db6a5964eb50b..ab51bb9239c6355434b10fb13786576d9818b363 100644 --- a/crates/unified/src/attachment.rs +++ b/crates/unified/src/attachment.rs @@ -33,7 +33,7 @@ pub struct Peer { #[derive(Component, Serialize, Deserialize, MapEntities)] #[relationship(relationship_target = Joints)] pub struct JointOf(#[entities] pub Entity); -#[derive(Component, Serialize, Deserialize, MapEntities, Debug)] +#[derive(Component, Serialize, Deserialize, MapEntities, Debug, Clone)] #[relationship_target(relationship = JointOf)] pub struct Joints(#[entities] Vec); impl Deref for Joints { diff --git a/crates/unified/src/client/mod.rs b/crates/unified/src/client/mod.rs index 395de5839eb68849a3aa5bc6c61a930512fd701c..161d4f58816921d537665b08160a5c61605c6e6b 100644 --- a/crates/unified/src/client/mod.rs +++ b/crates/unified/src/client/mod.rs @@ -26,7 +26,7 @@ use bevy::window::PrimaryWindow; use planet::incoming_planets::incoming_planets_plugin; pub struct ClientPlugin { - pub server: String, + pub server: Option, } impl Plugin for ClientPlugin { fn build(&self, app: &mut App) { @@ -34,15 +34,13 @@ impl Plugin for ClientPlugin { app .insert_resource(CursorWorldCoordinates(None)) .add_systems(Startup, move |mut commands: Commands| { + let Some(server) = server.as_ref() else { return }; let config = net::websocket_config(); commands .spawn(Name::new("default-session")) .queue(WebSocketClient::connect(config, server.clone())); }) - .add_observer(net::on_connecting) - .add_observer(net::on_connected) - .add_observer(net::on_disconnected) .add_systems(Startup, setup_graphics) .add_systems(Update, update_cursor_position) .add_systems(Update, follow_camera) @@ -55,6 +53,11 @@ impl Plugin for ClientPlugin { .add_plugins(ui_plugin) .add_plugins(zoom_plugin) .insert_resource(DebugPickingMode::Disabled); + if self.server.is_some() { + app.add_observer(net::on_connecting) + .add_observer(net::on_connected) + .add_observer(net::on_disconnected); + } } } diff --git a/crates/unified/src/client_plugins.rs b/crates/unified/src/client_plugins.rs index 900b16b238e1b31c21acfbc057d9729727c2e239..5ab472af8be8476d43d78cfb9692bbaed0771cc5 100644 --- a/crates/unified/src/client_plugins.rs +++ b/crates/unified/src/client_plugins.rs @@ -11,22 +11,19 @@ use bevy::ui::UiPlugin; use bevy_replicon::RepliconPlugins; pub struct ClientPluginGroup { - pub server: String, + pub server: Option, } impl PluginGroup for ClientPluginGroup { fn build(self) -> PluginGroupBuilder { PluginGroupBuilder::start::() - .add_group(DefaultPlugins.build().disable::()) - .add_group(RepliconPlugins) .add(WebSocketClientPlugin) .add(AeronetRepliconClientPlugin) .add(MeshPickingPlugin) .add(DebugPickingPlugin) - .add(UiPlugin) .add(ClientPlugin { server: self.server, }) - + .add(UiPlugin) //.add(PhysicsDebugPlugin) -- debug rendering //.add(FpsOverlayPlugin::default()) //.add(EguiPlugin::default()) diff --git a/crates/unified/src/lib.rs b/crates/unified/src/lib.rs deleted file mode 100644 index d933b3e33f1052fc156a57b560adbccbc51937d4..0000000000000000000000000000000000000000 --- a/crates/unified/src/lib.rs +++ /dev/null @@ -1,43 +0,0 @@ -#![warn(clippy::pedantic)] // Be annoying, and disable specific irritating lints if needed -#![deny( - clippy::allow_attributes_without_reason, - clippy::assertions_on_result_states -)] -#![warn(clippy::if_then_some_else_none)] -#![allow(clippy::type_complexity, reason = "Bevy makes this a nightmare")] -#![allow(clippy::needless_pass_by_value, reason = "Bevy makes this a nightmare")] -#![allow( - clippy::cast_precision_loss, - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - reason = "We cast ints to floats a lot" -)] -#![allow(clippy::missing_panics_doc, reason = "Gamedev! We panic a lot")] -#![allow(clippy::too_many_arguments, reason = "Le Bevy:tm:")] -#![allow( - clippy::too_many_lines, - reason = "With the three of us, this is impossible" -)] - -//! Primary entrypoint for the lib... mostly useful for wasm -#[cfg(target_arch = "wasm32")] -pub mod wasm_entrypoint; -#[cfg(target_arch = "wasm32")] -pub use wasm_entrypoint::*; - -pub mod attachment; -pub mod client; -pub mod client_plugins; -pub mod config; -pub mod ecs; -#[cfg(all(not(target_arch = "wasm32"), feature = "particle_editor"))] -pub mod particle_editor; -pub mod particles; -#[cfg(all(not(target_arch = "wasm32"), feature = "native"))] -pub mod server; -#[cfg(all(not(target_arch = "wasm32"), feature = "native"))] -pub mod server_plugins; -pub mod shared_plugins; - -pub mod physics; -pub mod prelude; \ No newline at end of file diff --git a/crates/unified/src/main.rs b/crates/unified/src/main.rs index c9fd3144419c44bbedb4ab3d950bcd496a72993f..6eae49b2a80c866d480260e5bdf646801f2fc401 100644 --- a/crates/unified/src/main.rs +++ b/crates/unified/src/main.rs @@ -1,16 +1,65 @@ -use bevy::log::tracing_subscriber; -use starkingdoms::prelude::*; +#![warn(clippy::pedantic)] // Be annoying, and disable specific irritating lints if needed +#![deny( + clippy::allow_attributes_without_reason, + clippy::assertions_on_result_states +)] +#![warn(clippy::if_then_some_else_none)] +#![allow(clippy::type_complexity, reason = "Bevy makes this a nightmare")] +#![allow(clippy::needless_pass_by_value, reason = "Bevy makes this a nightmare")] +#![allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "We cast ints to floats a lot" +)] +#![allow(clippy::missing_panics_doc, reason = "Gamedev! We panic a lot")] +#![allow(clippy::too_many_arguments, reason = "Le Bevy:tm:")] +#![allow( + clippy::too_many_lines, + reason = "With the three of us, this is impossible" +)] + +pub mod attachment; +pub mod client; +pub mod client_plugins; +pub mod config; +pub mod ecs; +#[cfg(all(not(target_arch = "wasm32"), feature = "particle_editor"))] +pub mod particle_editor; +pub mod particles; +#[cfg(all(not(target_arch = "wasm32"), feature = "native"))] +pub mod server; +#[cfg(all(not(target_arch = "wasm32"), feature = "native"))] +pub mod server_plugins; +pub mod shared_plugins; + +pub mod physics; +pub mod prelude; + +#[cfg(target_arch = "wasm32")] +pub mod wasm_entrypoint; +#[cfg(target_arch = "wasm32")] +pub use wasm_entrypoint::*; + +use bevy::log::{tracing_subscriber, LogPlugin}; +use crate::prelude::*; use clap::Parser; -use starkingdoms::client_plugins::ClientPluginGroup; +use crate::client_plugins::ClientPluginGroup; #[cfg(not(target_arch = "wasm32"))] -use starkingdoms::server_plugins::ServerPluginGroup; -use starkingdoms::shared_plugins::SharedPluginGroup; +use crate::server_plugins::ServerPluginGroup; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::process::exit; use std::str::FromStr; +use std::time::Duration; +use bevy::app::ScheduleRunnerPlugin; +use bevy::diagnostic::FrameCountPlugin; +use bevy::state::app::StatesPlugin; +use bevy::time::TimePlugin; +use bevy::ui::UiPlugin; use tracing_subscriber::EnvFilter; use tracing_subscriber::filter::Directive; use tracing_subscriber::util::SubscriberInitExt; +use crate::shared_plugins::SharedPluginGroup; #[derive(Parser, Debug, Clone)] #[command(version, about)] @@ -48,7 +97,12 @@ fn run(cli: Cli) -> AppExit { warn!("This can result in segfaults and inconsistent behavior! If there is weirdness, try disabling it."); warn!("-+-+-+-+-+-+- Starting with hotpatching enabled -+-+-+-+-+-+-"); } - app.add_plugins(ClientPluginGroup { server }); + app.add_plugins( + DefaultPlugins.build() + .disable::() + .disable::() + ); + app.add_plugins(ClientPluginGroup { server: Some(server) }); app.add_plugins(SharedPluginGroup); } #[cfg(not(target_arch = "wasm32"))] @@ -57,7 +111,7 @@ fn run(cli: Cli) -> AppExit { tick_rate, max_clients, hotpatching_enabled, - .. + with_client } => { if hotpatching_enabled { warn!("-+-+-+-+-+-+- Starting with hotpatching enabled -+-+-+-+-+-+-"); @@ -69,16 +123,45 @@ fn run(cli: Cli) -> AppExit { exit(1); } - app.add_plugins(ServerPluginGroup { + + if with_client { + app.add_plugins( + DefaultPlugins.build() + .disable::() + .disable::() + ); + app.add_plugins(|app: &mut App| { + app.add_systems(Startup, crate::server::player::join::ls_magically_invent_player); + }); + } else { + app + .add_plugins(AssetPlugin::default()) + .add_plugins(StatesPlugin) + .add_plugins(TaskPoolPlugin::default()) + .add_plugins(FrameCountPlugin) + .add_plugins(TimePlugin) + .add_plugins(ScheduleRunnerPlugin::run_loop(Duration::from_secs_f32( + 1.0 / tick_rate, + ))); + } + + app.add_plugins(SharedPluginGroup); + + let mut pg = ServerPluginGroup { bind, tick_rate, max_clients, - }); - app.add_plugins(SharedPluginGroup); + }.build(); + if with_client { + pg = pg.add_group(ClientPluginGroup { + server: None + }); + } + app.add_plugins(pg); } #[cfg(all(not(target_arch = "wasm32"), feature = "particle_editor"))] Cli::ParticleEditor {} => { - app.add_plugins(starkingdoms::particle_editor::particle_editor_plugin); + app.add_plugins(crate::particle_editor::particle_editor_plugin); } } @@ -104,6 +187,8 @@ fn main() -> AppExit { Cli::Client { .. } => { run(cli) }, Cli::ParticleEditor { .. } => { run(cli) }, Cli::Server { with_client, bind, hotpatching_enabled, .. } => { + run(cli) + /* if !with_client { run(cli) } else { @@ -162,8 +247,7 @@ fn main() -> AppExit { AppExit::Error(c) => AppExit::Error(c), _ => AppExit::Success } - } - } + }*/ } } } diff --git a/crates/unified/src/server/player.rs b/crates/unified/src/server/player.rs index 5cbc3c50324e01cfc45e4a1cb13b2e050480d0ea..60dca4d1ecf7a5565bfb1874727a853e362b1ddb 100644 --- a/crates/unified/src/server/player.rs +++ b/crates/unified/src/server/player.rs @@ -1,3 +1,5 @@ +pub mod join; + use crate::attachment::{Joint, JointOf, Joints, PartInShip, Peer, SnapOf, SnapOfJoint}; use crate::config::planet::Planet; use crate::ecs::{DragRequestEvent, Part, Player, PlayerStorage, PlayerThrust, ThrustEvent}; @@ -8,20 +10,168 @@ use crate::server::{ConnectedGameEntity, ConnectedNetworkEntity}; use crate::prelude::*; use bevy_replicon::prelude::{ClientId, FromClient}; use std::f32::consts::PI; +use crate::config::world::GlobalWorldConfig; pub fn player_management_plugin(app: &mut App) { app.add_systems( Update, ( - handle_new_players, + join::handle_pending_players, + join::handle_new_players, player_thrust, magic_fuel_regen, dragging, ) .in_set(PlayerInputSet), ); + app.add_systems(Update, complete_partial_disconnects); +} + +#[derive(Component)] +struct PartiallyDisconnected; + +/// Partial Disconnects are created when a part is disconnected to indicate that a disconnect has started. +/// Disconnects cannot be completed until the next tick, after relevant Peer relationships have been removed. +/// This system does this by performing a flood search to determine if the 'partial-disconnected' parts still have +/// a valid path to hearty; if they do not, they are removed from the ship. +fn complete_partial_disconnects( + partial_disconnected_parts: Query>, + mut q_joints: Query<&Joints>, + mut q_maybe_peer: Query>, + mut q_joint_of_part: Query<&JointOf>, + mut q_is_hearty: Query>, + mut dc_q_joints: Query<(&Joint, &JointOf, &Transform, Option<&Peer>, Entity)>, + mut dc_q_only_joints: Query<&Joints>, + + mut commands: Commands, +) { + for partially_disconnected_part in &partial_disconnected_parts { + + trace!(?partially_disconnected_part, "completing partial disconnect from previous tick"); + commands.entity(partially_disconnected_part).remove::(); + + // is it still connected to hearty? + let mut search_visited_joints = vec![]; + let can_reach_hearty = can_reach_hearty( + partially_disconnected_part, + q_joints.reborrow(), + q_maybe_peer.reborrow(), + q_joint_of_part.reborrow(), + q_is_hearty.reborrow(), + &mut search_visited_joints, + true + ); + if can_reach_hearty { + // great, leave them alone + continue; + } + // this part cannot reach hearty + // trigger a disconnect on them to propagate the disconnection + + let mut disconnect_queue = vec![]; + disconnect_part( + (partially_disconnected_part, match q_joints.get(partially_disconnected_part) { + Ok(j) => j, + Err(e) => { + warn!(?partially_disconnected_part, "part does not have a Joints? this should be impossible..."); + continue; + } + }), + &dc_q_joints, + &dc_q_only_joints, + &mut disconnect_queue, + commands.reborrow() + ); + commands.entity(partially_disconnected_part).remove::(); + } } +/// Determine if a part has a path to hearty by performing a depth-first search +/// TODO: This can be very slow on large ships- the path propagation model will be significantly faster +/// TODO: Ask core for an explanation of what the path propagation model is if you want to implement this +fn can_reach_hearty( + part: Entity, + + mut q_joints: Query<&Joints>, + mut q_maybe_peer: Query>, + mut q_joint_of_part: Query<&JointOf>, + mut q_is_hearty: Query>, + + mut visited_joints: &mut Vec, + + is_top_of_recursion: bool +) -> bool { + // Get the joints of this entity + let Ok(our_joints) = q_joints.get(part).cloned() else { + warn!("part does not have a Joints? this should be impossible..."); + return false; + }; + + // Iterate over each joint: + // if it's Hearty: great! we're connected + // if it's another part: recurse to it + // if it's not connected: lame, move on + 'to_next_joint: for joint in &**our_joints { + // mark that we've visited this joint, so we don't come back to it later + visited_joints.push(*joint); + + // does this joint have a peer? + let maybe_peer = q_maybe_peer.get(*joint).expect("cannot fail"); + + if let Some(peer_info) = maybe_peer { + // we have a peer! figure out what it's connected to... + let other_parts_joint = peer_info.peer_joint_entity_id; + // have we visited this joint already? + if visited_joints.contains(&other_parts_joint) { + // if so, move on + continue 'to_next_joint; + } + // we have not, find it's parent part + let other_part = q_joint_of_part.get(other_parts_joint).expect("joint is missing JointOf").0; + // is this part Hearty? + let maybe_is_hearty = q_is_hearty.get(other_part).expect("cannot fail"); + if maybe_is_hearty.is_some() { + // yay! found hearty + debug!("partial detach DFS: visited {} joints => found hearty @ {:?}", visited_joints.len(), other_part); + debug!("-> via {:?}", part); + return true; + } else { + // not hearty. but can the other part reach hearty? + let can_other_part_reach = can_reach_hearty( + other_part, + q_joints.reborrow(), + q_maybe_peer.reborrow(), + q_joint_of_part.reborrow(), + q_is_hearty.reborrow(), + visited_joints, + false + ); + if can_other_part_reach { + // great, they are connected + // log that we're in the path, then bubble up + debug!("-> via {:?}", part); + return true; + } else { + // lame. continue to next part + continue 'to_next_joint; + } + } + } else { + // we do not have a peer. move on + continue 'to_next_joint; + } + } + + // Exhausted all options; we are not connected to hearty bubble up + if is_top_of_recursion { + // print a debug message + debug!("partial detach DFS: visited {} joints => not connected", visited_joints.len()); + } + false +} + + + fn disconnect_part( (entity, joints): (Entity, &Joints), q_joints: &Query<(&Joint, &JointOf, &Transform, Option<&Peer>, Entity)>, @@ -54,18 +204,21 @@ fn disconnect_part( }; let Some(other_peer) = other_peer else { continue; }; commands.entity(peer.peer_joint_entity_id).remove::(); - processed_peers.push(peer.peer_joint_entity_id); + let Ok(other_joints) = q_only_joints.get(other_joint_of.0) else { continue }; + commands.entity(other_joint_of.0).remove::(); - if other_joint_of.0 != entity { - disconnect_part((peer.peer_joint_entity_id, joints), q_joints, + if !processed_peers.contains(&peer.peer_joint_entity_id) { + disconnect_part((other_joint_of.0, joints), q_joints, q_only_joints, processed_peers, commands.reborrow()); } + processed_peers.push(peer.peer_joint_entity_id); } // recursive disconnect part + commands.entity(entity).insert(PartiallyDisconnected); commands.entity(entity).remove::(); } @@ -89,6 +242,7 @@ fn dragging( q_joints: Query<&Joints>, q_joint: Query<&FixedJoint>, clients: Query<&ConnectedNetworkEntity>, + q_ls_me: Query>, mut commands: Commands, ) { for FromClient { @@ -96,13 +250,15 @@ fn dragging( message: event, } in events.read() { - let client_entity = match client_id { - ClientId::Client(e) => e, - _ => continue, + let player_hearty_entity = match client_id { + ClientId::Client(client_entity) => { + let ConnectedNetworkEntity { + game_entity: player_hearty_entity, + } = clients.get(*client_entity).unwrap(); + player_hearty_entity + }, + ClientId::Server => &q_ls_me.iter().next().unwrap() }; - let ConnectedNetworkEntity { - game_entity: player_hearty_entity, - } = clients.get(*client_entity).unwrap(); debug!(?event, "got drag request event"); @@ -272,55 +428,7 @@ fn dragging( } } -fn handle_new_players( - mut commands: Commands, - q_new_clients: Query>, - world_config: Res, - planets: Query<(&Transform, &Planet)>, - asset_server: Res, -) { - let Some(wc) = &world_config.config else { - return; - }; - for joined_player in &q_new_clients { - trace!(?joined_player, "detected joined player!"); - // find earth - let (spawn_planet_pos, spawn_planet) = planets - .iter() - .find(|p| p.1.name == wc.hearty.spawn_at) - .unwrap_or_else(|| { - panic!( - "spawn planet {} is missing? (check that the planet is named exactly '{}')", - wc.hearty.spawn_at, wc.hearty.spawn_at - ) - }); - let angle = rand::random::() * std::f32::consts::TAU; - let offset = spawn_planet.radius + 150.0; - let mut new_transform = - Transform::from_xyz(angle.cos() * offset, angle.sin() * offset, 0.0); - new_transform.rotate_z(angle); - new_transform.translation += spawn_planet_pos.translation; - - info!(?new_transform, ?joined_player, "set player's position!"); - - commands - .entity(joined_player) - .insert(new_transform) - .insert(SpawnPartRequest( - asset_server.load("config/parts/hearty.part.toml"), - )) - .insert(PlayerThrust::default()) - .insert(PlayerStorage { - fuel_capacity: 25.0, - fuel: 25.0, - power_capacity: 25.0, - power: 25.0, - }) - .insert(Player { - client: joined_player, - }); - } -} + fn magic_fuel_regen(players: Query<&mut PlayerStorage, With>, time: Res