~starkingdoms/starkingdoms

97a7f3ed1ee67a9ce3a05f453237b1de04f459f6 — core 28 days ago 6c1144c
chore(netcode-rewrite): code cleanups and refactoring
M crates/unified/src/client/components/mod.rs => crates/unified/src/client/components/mod.rs +1 -37
@@ 1,49 1,13 @@
use crate::prelude::{Component, Deserialize, GizmoConfigGroup, Handle, Image, Reflect, Resource, Serialize};
use crate::prelude::Component;

#[derive(Component)]
pub struct MainCamera;

#[derive(Component)]
pub struct StarguideCamera;

#[derive(Component)]
pub struct OrbitCamera;

#[derive(Default, Reflect, GizmoConfigGroup)]
pub struct StarguideGizmos;

#[derive(Component)]
pub struct StarfieldFront;

#[derive(Component)]
pub struct StarfieldMid;

#[derive(Component)]
pub struct StarfieldBack;

#[derive(Component)]
pub struct FuelText;

#[derive(Component)]
pub struct PowerText;

#[derive(Component)]
pub struct PlanetSensor(pub String);

#[derive(Component)]
pub struct Me;

#[derive(Component)]
pub struct StarguideMe;

#[derive(Resource)]
pub struct StarguideOrbitImage(pub Handle<Image>);

#[derive(Component)]
pub struct StarguideOrbit;

#[derive(Component, Serialize, Deserialize, Debug)]
pub struct CraftingUi;

#[derive(Component, Serialize, Deserialize, Debug)]
pub struct TemperatureSprite;
\ No newline at end of file

A crates/unified/src/client/crafting/components.rs => crates/unified/src/client/crafting/components.rs +4 -0
@@ 0,0 1,4 @@
use crate::prelude::{Component, Deserialize, Serialize};

#[derive(Component, Serialize, Deserialize, Debug)]
pub struct CraftingUi;
\ No newline at end of file

M crates/unified/src/client/crafting/mod.rs => crates/unified/src/client/crafting/mod.rs +1 -0
@@ 1,1 1,2 @@
pub mod ui;
pub mod components;

M crates/unified/src/client/crafting/ui.rs => crates/unified/src/client/crafting/ui.rs +2 -1
@@ 3,7 3,8 @@ use std::collections::HashMap;
use bevy::{input_focus::AutoFocus, ui::RelativeCursorPosition};

use crate::{client::colors, prelude::*};
use crate::client::components::{CraftingUi, MainCamera, Me};
use crate::client::components::{MainCamera, Me};
use crate::client::crafting::components::CraftingUi;
use crate::shared::attachment::PartInShip;
use crate::shared::config::recipe::RecipesConfig;
use crate::shared::ecs::{CanCraft, CraftPartRequest, Drill, Part, SingleStorage, ToggleDrillEvent, VariableStorage};

M crates/unified/src/client/mod.rs => crates/unified/src/client/mod.rs +1 -1
@@ 8,7 8,7 @@ use crate::client::ui::ui_plugin;
use crate::client::zoom::zoom_plugin;
use crate::client::starguide::init::starguide_init_plugin;
use crate::client::starguide::input::starguide_input_plugin;
use components::StarguideGizmos;
use starguide::components::StarguideGizmos;
use bevy::dev_tools::picking_debug::DebugPickingMode;
use crate::prelude::*;
use planet::incoming_planets::incoming_planets_plugin;

M crates/unified/src/client/parts.rs => crates/unified/src/client/parts.rs +49 -63
@@ 4,7 4,7 @@ use crate::shared::attachment::{Joint, JointOf, Joints, PartInShip, Peer, SnapOf
use crate::client::crafting::ui::open_crafting_ui;
use crate::shared::ecs::Temperature;
use crate::client::colors::GREEN;
use crate::shared::ecs::{DragRequestEvent, Part, MAIN_LAYER};
use crate::shared::ecs::{DragAction, DragRequestEvent, Part, MAIN_LAYER};
use crate::client::input::CursorWorldCoordinates;
use bevy::color::palettes::css::{ORANGE, PURPLE, RED, YELLOW};
use crate::client::components::Me;


@@ 12,8 12,8 @@ use crate::client::ship::attachment::AttachmentDebugRes;
use crate::prelude::*;

pub fn parts_plugin(app: &mut App) {
    app.insert_resource(DragResource(None));
    app.insert_resource(SnapResource(None, None));
    app.insert_resource(DragResource { dragged: None });
    app.insert_resource(SnapResource { target: None, peer: None });
    app.add_systems(
        Update,
        (


@@ 27,23 27,32 @@ pub fn parts_plugin(app: &mut App) {
    app.add_observer(on_part_release);
}

fn temp_to_color(temperature: &Temperature) -> Color {
    Color::srgb(1.0 + ((temperature.0 as f32 - 300.0) / 700.0).max(0.0), 1.0, 1.0)
}

fn build_part_sprite(part: &Part, temperature: &Temperature, is_connected: bool, asset_server: &AssetServer) -> Sprite {
    let path = if is_connected {
        &part.strong_config.part.sprite_connected
    } else {
        &part.strong_config.part.sprite_disconnected
    };
    let mut sprite = Sprite::from_image(asset_server.load(path));
    sprite.custom_size = Some(Vec2::new(
        part.strong_config.physics.width as f32,
        part.strong_config.physics.height as f32,
    ));
    sprite.color = temp_to_color(temperature);
    sprite
}

fn handle_incoming_parts(
    mut commands: Commands,
    new_parts: Query<(Entity, &Part, &Temperature, Option<&PartInShip>), Added<Part>>,
    asset_server: Res<AssetServer>,
) {
    for (new_entity, new_part, temperature, is_connected) in new_parts.iter() {
        let mut sprite = Sprite::from_image(asset_server.load(if is_connected.is_some() {
            &new_part.strong_config.part.sprite_connected
        } else {
            &new_part.strong_config.part.sprite_disconnected
        }));
        sprite.color = Color::srgb(1.0 + ((temperature.0 as f32 - 300.0) / 700.0).max(0.0), 1.0, 1.0);
        sprite.custom_size = Some(Vec2::new(
            new_part.strong_config.physics.width as f32,
            new_part.strong_config.physics.height as f32,
        ));

        let sprite = build_part_sprite(new_part, temperature, is_connected.is_some(), &asset_server);
        commands
            .entity(new_entity)
            .insert(MAIN_LAYER)


@@ 53,30 62,22 @@ fn handle_incoming_parts(
            .observe(open_crafting_ui);
    }
}

fn handle_updated_temperature(
    mut updated_parts: Query<(&mut Sprite, &Temperature), (With<Part>, Changed<Temperature>)>
) {
    for (mut sprite, temperature) in updated_parts.iter_mut() {
        sprite.color = Color::srgb(1.0 + ((temperature.0 as f32 - 300.0) / 700.0).max(0.0), 1.0, 1.0);
        sprite.color = temp_to_color(temperature);
    }
}

fn handle_updated_parts(
    mut commands: Commands,
    updated_parts: Query<(Entity, &Part, &Temperature, Option<&PartInShip>), Changed<Part>>,
    asset_server: Res<AssetServer>,
) {
    for (updated_entity, updated_part, temperature, is_connected) in updated_parts.iter() {
        let mut sprite = Sprite::from_image(asset_server.load(if is_connected.is_some() {
            &updated_part.strong_config.part.sprite_connected
        } else {
            &updated_part.strong_config.part.sprite_disconnected
        }));
        sprite.custom_size = Some(Vec2::new(
            updated_part.strong_config.physics.width as f32,
            updated_part.strong_config.physics.height as f32,
        ));
        sprite.color = Color::srgb(1.0 + ((temperature.0 as f32 - 300.0) / 700.0).max(0.0), 1.0, 1.0);

        let sprite = build_part_sprite(updated_part, temperature, is_connected.is_some(), &asset_server);
        commands
            .entity(updated_entity)
            .remove::<Sprite>()


@@ 95,37 96,23 @@ fn update_part_sprites(
        let Ok((part, temperature, connected_to)) = parts.get(e) else {
            continue;
        };

        let sprite = if connected_to.is_some() {
            &part.strong_config.part.sprite_connected
        } else {
            &part.strong_config.part.sprite_disconnected
        };

        let mut sprite = Sprite::from_image(asset_server.load(sprite));
        sprite.custom_size = Some(Vec2::new(
            part.strong_config.physics.width as f32,
            part.strong_config.physics.height as f32,
        ));
        sprite.color = Color::srgb(1.0 + ((temperature.0 as f32 - 300.0) / 700.0).max(0.0), 1.0, 1.0);
        //sprite.color = Color::srgb(1.0, (700.0 - temperature.0) / 700.0, (700.0 - temperature.0) / 700.0);

        let sprite = build_part_sprite(part, temperature, connected_to.is_some(), &asset_server);
        commands.entity(e).insert(sprite);
    }
}

#[derive(Resource)]
struct DragResource(Option<Entity>);
struct DragResource { dragged: Option<Entity> }
#[derive(Resource)]
struct SnapResource(Option<Entity>, Option<Entity>);
struct SnapResource { target: Option<Entity>, peer: Option<Entity> }

#[derive(Component)]
struct DragGhost;
#[derive(Component)]
struct Ghost {
    pub rot: Quat,
    pub last_target_pos: Vec3,
    pub vel: Vec3
    pub spring_pos: Vec3,
    pub spring_vel: Vec3,
}

const ROTATION_SMOOTH: f32 = 0.1;


@@ 157,11 144,11 @@ fn on_part_click(
    s.color = Color::srgba(0.7, 0.7, 0.7, 1.0);
    commands.spawn((DragGhost, Ghost {
        rot: sprite.1.rotation,
        last_target_pos: sprite.1.translation,
        vel: Vec3::ZERO,
        spring_pos: sprite.1.translation,
        spring_vel: Vec3::ZERO,
    }, *sprite.1, s));

    drag.0 = Some(ev.event().event_target());
    drag.dragged = Some(ev.event().event_target());
}

fn on_part_release(


@@ 177,7 164,7 @@ fn on_part_release(
        return;
    }

    if let Some(e) = drag.0 {
    if let Some(e) = drag.dragged {
        let rotation = ghost.1.rotation;
        commands.entity(ghost.0).despawn();



@@ 185,15 172,15 @@ fn on_part_release(
            debug!(?e, ?c, "sending drag request");
            events.write(DragRequestEvent {
                drag_target: e,
                drag_to: c,
                set_rotation: rotation,
                snap_target: snap.0,
                peer_snap: snap.1,
                action: match (snap.target, snap.peer) {
                    (Some(snap_target), Some(peer_snap)) => DragAction::Attach { snap_target, peer_snap },
                    _ => DragAction::Free { position: c, rotation },
                },
            });
        }
    }

    drag.0 = None;
    drag.dragged = None;
}

const F: f32 = 6.0; // frequency (Hz)


@@ 291,7 278,7 @@ fn update_drag_ghosts(
    let mut best_self_position = None;

    for (snap_local_transform, snap_joint, snap_part, snap_id) in &snaps {
        if Some(snap_part.0) == drag.0 {
        if Some(snap_part.0) == drag.dragged {
            continue;
        }



@@ 355,7 342,7 @@ fn update_drag_ghosts(
        let mut best_peer_target_pos = None;
        let mut best_joint_transform = None;
        for (our_snap_local_transform, our_snap_joint, our_snap_part, our_snap_id) in &snaps {
            if Some(our_snap_part.0) != drag.0 {
            if Some(our_snap_part.0) != drag.dragged {
                continue;
            }
            let our_snap_global_translation =


@@ 412,23 399,22 @@ fn update_drag_ghosts(
    ghost_info.rot = best_target.rotation;
    ghost.rotation = ghost.rotation.slerp(ghost_info.rot, ROTATION_SMOOTH);

    let target_vel = (best_target.translation - ghost_info.last_target_pos)
        /time.delta_secs();
    let target_vel = (best_target.translation - ghost_info.spring_pos) / time.delta_secs();


    //let a = (best_target.translation - ghost.translation
    //    + K3*target_vel - K1*ghost_info.vel)/K2;
    let (r_k1, v_k1, r_k2, v_k2, r_k3, v_k3, r_k4, v_k4) = rk4_k_values(best_target.translation, target_vel, ghost.translation, ghost_info.vel, time.delta_secs());
    let (r_k1, v_k1, r_k2, v_k2, r_k3, v_k3, r_k4, v_k4) = rk4_k_values(best_target.translation, target_vel, ghost.translation, ghost_info.spring_vel, time.delta_secs());

    ghost.translation += (r_k1 + 2.0*r_k2 + 2.0*r_k3 + r_k4)/6.0*time.delta_secs();
    ghost_info.vel += (v_k1 + 2.0*v_k2 + 2.0*v_k3 + v_k4)/6.0*time.delta_secs();
    ghost_info.spring_vel += (v_k1 + 2.0*v_k2 + 2.0*v_k3 + v_k4)/6.0*time.delta_secs();

    ghost_info.last_target_pos = best_target.translation;
    ghost_info.spring_pos = best_target.translation;

    let partvel = linvel.single().unwrap();
    ghost.translation.x += partvel.x as f32 * time.delta_secs();
    ghost.translation.y += partvel.y as f32 * time.delta_secs();

    rsnap.0 = snap;
    rsnap.1 = best_self_snap;
    rsnap.target = snap;
    rsnap.peer = best_self_snap;
}

M crates/unified/src/client/rendering/mod.rs => crates/unified/src/client/rendering/mod.rs +2 -1
@@ 2,7 2,8 @@ use bevy::anti_alias::fxaa::Fxaa;
use bevy::app::{App, Startup};
use bevy::core_pipeline::tonemapping::DebandDither;
use bevy::post_process::bloom::Bloom;
use crate::client::components::{MainCamera, Me, StarguideGizmos};
use crate::client::components::{MainCamera, Me};
use crate::client::starguide::components::StarguideGizmos;
use crate::shared::ecs::{GameplayState, MAIN_LAYER, STARGUIDE_LAYER};
use crate::prelude::*;


M crates/unified/src/client/ship/thrusters.rs => crates/unified/src/client/ship/thrusters.rs +14 -24
@@ 55,6 55,9 @@ fn draw_thruster_debug(
    }
}

const THRUSTER_CONTRIBUTION_EPSILON: f32 = 0.1;
const THRUST_ACTIVATION_THRESHOLD: f64 = 0.8;

// TODO(core): replace goodlp with calling clarabel directly and cache models
/// The thrust solver!
/// This is an annoyingly complicated function...


@@ 203,7 206,7 @@ fn solve_thrust(
            // Although all the numbers going in were normalized, the torque output is in different
            // units and is wacky. Re-normalize it to a set of expected values, since this is all
            // direction based anyway.
            let renormalized_thruster_torque = if thruster_torque.abs() < 0.1 {
            let renormalized_thruster_torque = if thruster_torque.abs() < THRUSTER_CONTRIBUTION_EPSILON {
                0.0 // This thruster's effect is small enough to be ignored
            } else if thruster_torque < 0.0 {
                -1.0 // if it's negative, force to -1


@@ 234,7 237,7 @@ fn solve_thrust(
    // calculate thrust ~~and torque~~ values

    /*
    Consult the paper for more information.
    Consult the paper for more information. (https://typst.app/project/rGBlHvhGRntdfxOjzEL8EO)

    Recall that we're optimizing an equation of form i_0 * x_0 + i_1 * x_1 + i_2 * x_2 ... i_n * x_n
    "Coefficients" are i_0 ... i_n, and can be precomputed, and x_0 ... x_n is the "decision variables"


@@ 261,7 264,7 @@ fn solve_thrust(
            // if thrust coefficient is <0.1, zap it entirely (this thruster is not helping)
            // This is done elsewhere for torque, so pass it (u.1) through unchanged
            // TODO(core): figure out how to make this adjustable
            if u.0.abs() < 0.1 {
            if u.0.abs() < THRUSTER_CONTRIBUTION_EPSILON {
                (0.0, u.1)
            } else {
                (u.0, u.1)


@@ 319,17 322,13 @@ fn solve_thrust(
        }
    };

    // did the solution converge?
    match thrust_solution.status() {
        SolutionStatus::Optimal => {}, // yay!
        SolutionStatus::TimeLimit => {
            warn!("thrust solver failed to converge, hit time limit");
        }
        SolutionStatus::GapLimit => {
            warn!("thrust solver failed to converge, hit gap limit");
        }
    }
    let check_convergence = |status, label: &str| match status {
        SolutionStatus::Optimal => {},
        SolutionStatus::TimeLimit => warn!("{label} solver failed to converge, hit time limit"),
        SolutionStatus::GapLimit  => warn!("{label} solver failed to converge, hit gap limit"),
    };

    check_convergence(thrust_solution.status(), "thrust");
    trace!("finished thrust solve @ {:?}", start.elapsed());
    trace!("starting torque solve @ {:?}", start.elapsed());



@@ 342,16 341,7 @@ fn solve_thrust(
        }
    };

    // did the solution converge?
    match torque_solution.status() {
        SolutionStatus::Optimal => {}, // yay!
        SolutionStatus::TimeLimit => {
            warn!("torque solver failed to converge, hit time limit");
        }
        SolutionStatus::GapLimit => {
            warn!("torque solver failed to converge, hit gap limit");
        }
    }
    check_convergence(torque_solution.status(), "torque");

    trace!("finished torque solve @ {:?}ms", start.elapsed());



@@ 371,7 361,7 @@ fn solve_thrust(
        // is above 80%.
        // The solver seems to be picking 0.0 or 1.0 in all circumstances anyway, but just in case.

        if thrust_solution.value(variables[thruster.0].0.1) > 0.8 || torque_solution.value(variables[thruster.0].1.1) > 0.8 {
        if thrust_solution.value(variables[thruster.0].0.1) > THRUST_ACTIVATION_THRESHOLD || torque_solution.value(variables[thruster.0].1.1) > THRUST_ACTIVATION_THRESHOLD {
            new_soln.thrusters_on.insert(*thruster.1.0);
        }
    }

M crates/unified/src/client/starfield.rs => crates/unified/src/client/starfield.rs +2 -1
@@ 15,9 15,10 @@ use bevy::{
    transform::components::Transform,
    window::{Window, WindowResized},
};
use crate::client::components::{MainCamera, StarfieldBack, StarfieldFront, StarfieldMid};
use crate::client::components::MainCamera;
use crate::shared::ecs::MAIN_LAYER;
use crate::client::components::Me;
use crate::client::starguide::components::{StarfieldBack, StarfieldFront, StarfieldMid};

pub const BACK_STARFIELD_SIZE: f32 = 256.0;
pub const MID_STARFIELD_SIZE: f32 = 384.0;

A crates/unified/src/client/starguide/components.rs => crates/unified/src/client/starguide/components.rs +25 -0
@@ 0,0 1,25 @@
use crate::prelude::{Component, GizmoConfigGroup, Handle, Image, Reflect, Resource};

#[derive(Component)]
pub struct StarguideMe;

#[derive(Resource)]
pub struct StarguideOrbitImage(pub Handle<Image>);

#[derive(Component)]
pub struct StarguideOrbit;

#[derive(Component)]
pub struct StarguideCamera;

#[derive(Default, Reflect, GizmoConfigGroup)]
pub struct StarguideGizmos;

#[derive(Component)]
pub struct StarfieldFront;

#[derive(Component)]
pub struct StarfieldMid;

#[derive(Component)]
pub struct StarfieldBack;
\ No newline at end of file

M crates/unified/src/client/starguide/init.rs => crates/unified/src/client/starguide/init.rs +2 -1
@@ 1,7 1,8 @@
use bevy::anti_alias::fxaa::Fxaa;
use bevy::core_pipeline::tonemapping::DebandDither;
use bevy::post_process::bloom::Bloom;
use crate::client::components::{Me, StarguideCamera, StarguideMe, StarguideOrbit};
use crate::client::components::Me;
use crate::client::starguide::components::{StarguideCamera, StarguideMe, StarguideOrbit};
use crate::prelude::*;
use crate::shared::ecs::{Part, STARGUIDE_LAYER};


M crates/unified/src/client/starguide/input.rs => crates/unified/src/client/starguide/input.rs +1 -1
@@ 1,6 1,6 @@
use crate::prelude::*;
use crate::client::input::CursorWorldCoordinates;
use crate::client::components::StarguideCamera;
use crate::client::starguide::components::StarguideCamera;

pub fn starguide_input_plugin(app: &mut App) {
    app

M crates/unified/src/client/starguide/mod.rs => crates/unified/src/client/starguide/mod.rs +1 -0
@@ 1,3 1,4 @@
pub mod init;
pub mod input;
pub mod orbit;
pub mod components;

M crates/unified/src/client/starguide/orbit.rs => crates/unified/src/client/starguide/orbit.rs +2 -1
@@ 1,5 1,6 @@
use std::f64::consts::PI;
use crate::client::components::{Me, StarguideCamera, StarguideGizmos};
use crate::client::components::Me;
use crate::client::starguide::components::{StarguideCamera, StarguideGizmos};
use crate::prelude::*;
use crate::shared::config::planet::Planet;
use crate::shared::world_config::WorldConfigResource;

M crates/unified/src/client/zoom.rs => crates/unified/src/client/zoom.rs +4 -4
@@ 2,8 2,9 @@ use bevy::{
    input::mouse::{MouseScrollUnit, MouseWheel},
    prelude::*,
};
use crate::client::components::{MainCamera, Me, OrbitCamera, StarfieldBack, StarfieldFront, StarfieldMid, StarguideCamera};
use crate::client::components::{MainCamera, Me};
use crate::client::starfield::{StarfieldSize, BACK_STARFIELD_SIZE, FRONT_STARFIELD_SIZE, MID_STARFIELD_SIZE};
use crate::client::starguide::components::{StarfieldBack, StarfieldFront, StarfieldMid, StarguideCamera};
use crate::shared::ecs::GameplayState;
pub fn zoom_plugin(app: &mut App) {
    app.add_systems(Update, on_scroll);


@@ 52,7 53,7 @@ fn on_scroll(
    /*mut orbit_camera: Single<
        &mut Camera,
        (
            With<OrbitCamera>,

            Without<StarguideCamera>,
            Without<MainCamera>,
            Without<Me>,


@@ 65,7 66,6 @@ fn on_scroll(
        (&mut Camera, &mut Projection),
        (
            With<MainCamera>,
            Without<OrbitCamera>,
            Without<StarguideCamera>,
            Without<Me>,
            Without<StarfieldFront>,


@@ 92,7 92,7 @@ fn on_scroll(
            Without<StarfieldMid>,
            Without<StarfieldBack>,
            Without<MainCamera>,
            Without<OrbitCamera>,

            Without<StarguideCamera>,
        ),
    >,*/

A crates/unified/src/server/components/mod.rs => crates/unified/src/server/components/mod.rs +4 -0
@@ 0,0 1,4 @@
use crate::prelude::Component;

#[derive(Component)]
pub struct PlanetSensor(pub String);
\ No newline at end of file

M crates/unified/src/server/drill.rs => crates/unified/src/server/drill.rs +1 -1
@@ 1,4 1,4 @@
use crate::client::components::PlanetSensor;
use crate::server::components::PlanetSensor;
use crate::prelude::*;
use crate::shared::attachment::{PartInShip, Parts};
use crate::shared::config::planet::Planet;

M crates/unified/src/server/mod.rs => crates/unified/src/server/mod.rs +1 -0
@@ 10,6 10,7 @@ pub mod player;
mod system_sets;
pub mod orbit;
pub mod plugins;
pub mod components;

use crate::server::craft::craft_plugin;
use crate::server::damping::damping_plugin;

M crates/unified/src/server/planets.rs => crates/unified/src/server/planets.rs +1 -1
@@ 1,4 1,4 @@
use crate::client::components::PlanetSensor;
use crate::server::components::PlanetSensor;
use bevy::{asset::Handle, math::DVec3};
use crate::prelude::*;
use crate::shared::config::planet::{PlanetSpring, PlanetSpringJoint};

M crates/unified/src/server/player.rs => crates/unified/src/server/player.rs +130 -163
@@ 2,7 2,7 @@ pub mod join;
pub mod thrust;

use crate::shared::attachment::{Joint, JointOf, Joints, PartInShip, Peer, SnapOf, SnapOfJoint};
use crate::shared::ecs::{DragRequestEvent, Part, Player, PlayerStorage};
use crate::shared::ecs::{DragAction, DragRequestEvent, Part, Player, PlayerStorage};
use crate::server::damping::ModuleJointDamping;
use crate::server::system_sets::PlayerInputSet;
use crate::prelude::*;


@@ 50,25 50,24 @@ fn complete_partial_disconnects(

        // is it still connected to hearty?
        let mut search_visited_joints = vec![];
        let can_reach_hearty = can_reach_hearty(
        let connected = is_connected_to_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 {
        if connected {
            // great, leave them alone
            continue;
        }
        // this part cannot reach hearty
        // trigger a disconnect on them to propagate the disconnection
        debug!("partial detach DFS: visited {} joints => not connected", search_visited_joints.len());
        // this part cannot reach hearty; propagate the disconnection

        let mut disconnect_queue = vec![];
        commands.entity(partially_disconnected_part).remove::<PartInShip>(); // they're no longer in the ship, remove them from the meta
        disconnect_part(
        propagate_disconnect(
            (partially_disconnected_part, if let Ok(j) = q_joints.get(partially_disconnected_part) { j } else {
                warn!(?partially_disconnected_part, "part does not have a Joints? this should be impossible...");
                continue;


@@ 82,10 81,10 @@ fn complete_partial_disconnects(
    }
}

/// Determine if a part has a path to hearty by performing a depth-first search
/// Determine if a part is connected 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(
fn is_connected_to_hearty(
    part: Entity,

    mut q_joints: Query<&Joints>,


@@ 94,8 93,6 @@ fn can_reach_hearty(
    mut q_is_hearty: Query<Option<&Player>>,

    visited_joints: &mut Vec<Entity>,

    is_top_of_recursion: bool
) -> bool {
    // Are we hearty?
    if let Ok(Some(_)) = q_is_hearty.get(part) {


@@ 138,19 135,16 @@ fn can_reach_hearty(
                debug!("-> via {:?}", part);
                return true;
            }
            // not hearty. but can the other part reach hearty?
            let can_other_part_reach = can_reach_hearty(
            // not hearty, but can the other part reach hearty?
            let other_connected = is_connected_to_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
            if other_connected {
                debug!("-> via {:?}", part);
                return true;
            }


@@ 158,17 152,12 @@ fn can_reach_hearty(
        }
    }

    // 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(
fn propagate_disconnect(
    (entity, joints): (Entity, &Joints),
    q_joints: &Query<(&Joint, &JointOf, &Transform, Option<&Peer>, Entity)>,
    q_only_joints: &Query<&Joints>,


@@ 206,7 195,7 @@ fn disconnect_part(
        //commands.entity(other_joint_of.0).remove::<PartInShip>();

        if !processed_peers.contains(&peer.peer_joint_entity_id) {
            disconnect_part((other_joint_of.0, joints), q_joints,
            propagate_disconnect((other_joint_of.0, joints), q_joints,
                q_only_joints, processed_peers, commands.reborrow());
        }



@@ 251,160 240,138 @@ fn dragging(
        let mut new_linvel = None;
        let mut new_angvel = None;

        if let Some(snap_to) = event.snap_target
            && let Some(peer_snap) = event.peer_snap
        {
            let Ok(snap_on_target) = snaps.get(snap_to) else {
                continue;
            };
            let Ok(snap_on_source) = snaps.get(peer_snap) else {
                continue;
            };
        match &event.action {
            DragAction::Attach { snap_target, peer_snap } => {
                let Ok((target_snap_part, target_snap_joint)) = snaps.get(*snap_target) else {
                    continue;
                };
                let Ok((source_snap_part, source_snap_joint)) = snaps.get(*peer_snap) else {
                    continue;
                };

            let Ok(target_joint) = joints.get(snap_on_target.1.0) else {
                continue;
            };
            let Ok(source_joint) = joints.get(snap_on_source.1.0) else {
                continue;
            };
                let Ok((target_jt, target_jt_of, target_jt_xform, target_jt_peer, target_jt_id)) = joints.get(target_snap_joint.0) else {
                    continue;
                };
                let Ok((source_jt, source_jt_of, _source_jt_xform, source_jt_peer, source_jt_id)) = joints.get(source_snap_joint.0) else {
                    continue;
                };

            // validation step 1: everything must match. if not, ignore the request
            if snap_on_target.0.0 != target_joint.1.0 {
                warn!(
                    "drag request: mismatched target entities (potential manipulation?), ignoring"
                );
                continue;
            }
            if snap_on_source.0.0 != source_joint.1.0 {
                warn!(
                    "drag request: mismatched source entities (potential manipulation?), ignoring request"
                );
                continue;
            }
                // validation: snap ownership must agree with joint ownership
                if target_snap_part.0 != target_jt_of.0 {
                    warn!("drag request: mismatched target entities (potential manipulation?), ignoring");
                    continue;
                }
                if source_snap_part.0 != source_jt_of.0 {
                    warn!("drag request: mismatched source entities (potential manipulation?), ignoring request");
                    continue;
                }

            // we've passed initial validation.
            // do not allow drags with the source or destination if they already have a peer (are attached)
            if target_joint.3.is_some() {
                warn!(
                    "drag request: cannot attach to a joint that already has a peer, ignoring request"
                );
                continue;
            }
            if source_joint.3.is_some() {
                warn!(
                    "drag request: dragging from a part that is already attached is currently not supported, ignoring request"
                );
                continue;
            }
                // do not allow drags where either joint already has a peer (is attached)
                if target_jt_peer.is_some() {
                    warn!("drag request: cannot attach to a joint that already has a peer, ignoring request");
                    continue;
                }
                if source_jt_peer.is_some() {
                    warn!("drag request: dragging from a part that is already attached is currently not supported, ignoring request");
                    continue;
                }

            // great, the attachment appears to be valid
            // let's make sure this player is allowed to drag onto this part
            // getting attached to (hearty)
            let target_part = {
                let Ok(target_part) = parts.get(target_joint.1.0) else {
                // getting attached to (hearty)
                let Ok((target_xform, target_in_ship, target_entity, target_linvel, _, target_angvel, _)) = parts.get(target_jt_of.0) else {
                    continue;
                };
                target_part
            };

            // attached (housing)
            let source_part = {
                let Ok(source_part) = parts.get(source_joint.1.0) else {
                // attached (housing)
                let Ok((_, _, source_entity, _, source_joints, _, _)) = parts.get(source_jt_of.0) else {
                    continue;
                };
                source_part
            };

            let allowed = target_joint.1.0 == player_hearty_entity
                || target_part.1.is_some_and(|u| u.0 == player_hearty_entity);
            if !allowed {
                warn!("drag request: this player cannot move this part, ignoring request");
                continue;
            }

            // great, we have a valid peering request

            let mut processed = vec![source_joint.4];
            disconnect_part(
                (source_part.2, source_part.4),
                &joints,
                &q_joints,
                &mut processed,
                commands.reborrow(),
            );
                let allowed = target_jt_of.0 == player_hearty_entity
                    || target_in_ship.is_some_and(|u| u.0 == player_hearty_entity);
                if !allowed {
                    warn!("drag request: this player cannot move this part, ignoring request");
                    continue;
                }

                let mut processed = vec![source_jt_id];
                propagate_disconnect(
                    (source_entity, source_joints),
                    &joints,
                    &q_joints,
                    &mut processed,
                    commands.reborrow(),
                );

            // create the joint...
            let joint = FixedJoint::new(target_part.2, source_part.2)
                .with_local_anchor1(target_joint.2.translation.xy().into())
                .with_local_basis1(target_joint.0.transform.rotation.to_euler(EulerRot::ZYX).0 as f64 + PI
                    - source_joint.0.transform.rotation.to_euler(EulerRot::ZYX).0 as f64)
                .with_point_compliance(world_config.part.joint_point_compliance)
                .with_angle_compliance(world_config.part.joint_angle_compliance);
            let joint_damping = ModuleJointDamping {
                distance: world_config.part.joint_distance_damping,
                angular: world_config.part.joint_angular_damping,
            };

            let joint_id = commands.spawn((joint, joint_damping)).id();

            // create the peering component...
            commands.entity(source_joint.4).insert(Peer {
                peer_joint_entity_id: target_joint.4,
                physics_joint: joint_id
            });
            commands.entity(target_joint.4).insert(Peer {
                peer_joint_entity_id: source_joint.4,
                physics_joint: joint_id
            });

            // propagate PartInShip...

            let part_in_ship = if target_joint.1.0 == player_hearty_entity {
                PartInShip(player_hearty_entity)
            } else {
                PartInShip(target_part.1.unwrap().0) // unwrap: checked above (during 'allowed' calculation)
            };

            commands.entity(source_part.2).insert(part_in_ship);

            let target_position = target_part.0.mul_transform(*target_joint.2);

            teleport_to_translation = target_position.translation.xy();
            teleport_to_rotation = target_position.rotation
                * source_joint.0.transform.rotation.inverse()
                * Quat::from_rotation_z(PI as f32);
            new_linvel = Some(*target_part.3);
            new_angvel = Some(*target_part.5);
            // and we're done!
        } else {
            warn!(
                "blindly accepting non-attachment request, someone should change this eventually"
            );
            warn!("dragging already attached entities may cause inconsistent behavior!!");
            let source_part = parts.get(event.drag_target).unwrap();
            let mut processed = vec![];
            disconnect_part(
                (source_part.2, source_part.4),
                &joints,
                &q_joints,
                &mut processed,
                commands.reborrow(),
            );
            teleport_to_translation = event.drag_to;
            teleport_to_rotation = event.set_rotation;
                // create the physics joint
                let fixed_joint = FixedJoint::new(target_entity, source_entity)
                    .with_local_anchor1(target_jt_xform.translation.xy().into())
                    .with_local_basis1(target_jt.transform.rotation.to_euler(EulerRot::ZYX).0 as f64 + PI
                        - source_jt.transform.rotation.to_euler(EulerRot::ZYX).0 as f64)
                    .with_point_compliance(world_config.part.joint_point_compliance)
                    .with_angle_compliance(world_config.part.joint_angle_compliance);
                let joint_damping = ModuleJointDamping {
                    distance: world_config.part.joint_distance_damping,
                    angular: world_config.part.joint_angular_damping,
                };
                let joint_id = commands.spawn((fixed_joint, joint_damping)).id();

                // create the peering components
                commands.entity(source_jt_id).insert(Peer {
                    peer_joint_entity_id: target_jt_id,
                    physics_joint: joint_id,
                });
                commands.entity(target_jt_id).insert(Peer {
                    peer_joint_entity_id: source_jt_id,
                    physics_joint: joint_id,
                });

                // propagate PartInShip
                let part_in_ship = if target_jt_of.0 == player_hearty_entity {
                    PartInShip(player_hearty_entity)
                } else {
                    PartInShip(target_in_ship.unwrap().0) // unwrap: checked above (during 'allowed' calculation)
                };
                commands.entity(source_entity).insert(part_in_ship);

                let target_position = target_xform.mul_transform(*target_jt_xform);
                teleport_to_translation = target_position.translation.xy();
                teleport_to_rotation = target_position.rotation
                    * source_jt.transform.rotation.inverse()
                    * Quat::from_rotation_z(PI as f32);
                new_linvel = Some(*target_linvel);
                new_angvel = Some(*target_angvel);
            }
            DragAction::Free { position, rotation } => {
                warn!("blindly accepting non-attachment request, someone should change this eventually");
                warn!("dragging already attached entities may cause inconsistent behavior!!");
                let Ok((_, _, free_entity, _, free_joints, _, _)) = parts.get(event.drag_target) else {
                    continue;
                };
                let mut processed = vec![];
                propagate_disconnect(
                    (free_entity, free_joints),
                    &joints,
                    &q_joints,
                    &mut processed,
                    commands.reborrow(),
                );
                teleport_to_translation = *position;
                teleport_to_rotation = *rotation;
            }
        }

        let mut part = parts.get_mut(event.drag_target).unwrap();
        part.0.translation.x = teleport_to_translation.x;
        part.0.translation.y = teleport_to_translation.y;
        part.0.rotation = teleport_to_rotation; // client calculates this; no reason to recalculate
        if let Some(new_vel) = new_linvel {
            *part.3 = new_vel;
        let Ok((mut xform, _, _, mut linvel, _, mut angvel, _)) = parts.get_mut(event.drag_target) else {
            continue;
        };
        xform.translation.x = teleport_to_translation.x;
        xform.translation.y = teleport_to_translation.y;
        xform.rotation = teleport_to_rotation; // client calculates this; no reason to recalculate
        if let Some(vel) = new_linvel {
            *linvel = vel;
        }
        if let Some(new_vel) = new_angvel {
            *part.5 = new_vel;
        if let Some(vel) = new_angvel {
            *angvel = vel;
        }
        // ( the math sucks )
    }
}


M crates/unified/src/shared/ecs.rs => crates/unified/src/shared/ecs.rs +7 -4
@@ 42,10 42,13 @@ pub struct Player {
#[derive(Message, Debug, Clone)]
pub struct DragRequestEvent {
    pub drag_target: Entity,
    pub drag_to: Vec2,
    pub set_rotation: Quat,
    pub snap_target: Option<Entity>,
    pub peer_snap: Option<Entity>,
    pub action: DragAction,
}

#[derive(Debug, Clone)]
pub enum DragAction {
    Attach { snap_target: Entity, peer_snap: Entity },
    Free   { position: Vec2, rotation: Quat },
}

#[derive(Component, Serialize, Deserialize, Debug)]