~starkingdoms/starkingdoms

2c4d0320bff18167d8018458bd30067e124d1d07 — core 23 days ago 5458931
feat: the most complicated thrust system OF ALL TIME! (certified)
M .cargo/config.toml => .cargo/config.toml +1 -1
@@ 6,7 6,7 @@ xtask = "run --release --package xtask --"

[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold", "-Zshare-generics=y"]
rustflags = ["-C", "link-arg=-fuse-ld=mold"]

[target.wasm32-unknown-unknown]
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']

M Cargo.lock => Cargo.lock +181 -1
@@ 1189,6 1189,22 @@ dependencies = [
]

[[package]]
name = "bevy_gilrs"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28ff35087f25406006338e6d57f31f313a60f3a5e09990ab7c7b5203b0b55077"
dependencies = [
 "bevy_app",
 "bevy_ecs",
 "bevy_input",
 "bevy_platform",
 "bevy_time",
 "gilrs",
 "thiserror 2.0.17",
 "tracing",
]

[[package]]
name = "bevy_gizmos"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 1321,6 1337,7 @@ dependencies = [
 "bevy_dev_tools",
 "bevy_diagnostic",
 "bevy_ecs",
 "bevy_gilrs",
 "bevy_gizmos",
 "bevy_image",
 "bevy_input",


@@ 3163,6 3180,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"

[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"

[[package]]
name = "dyn-eq"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c2d035d21af5cde1a6f5c7b444a5bf963520a9f142e5d06931178433d7d5388"

[[package]]
name = "dyn-hash"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15401da73a9ed8c80e3b2d4dc05fe10e7b72d7243b9f614e516a44fa99986e88"

[[package]]
name = "ecolor"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 3978,6 4013,40 @@ dependencies = [
]

[[package]]
name = "gilrs"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb2c998745a3c1ac90f64f4f7b3a54219fd3612d7705e7798212935641ed18f"
dependencies = [
 "fnv",
 "gilrs-core",
 "log",
 "uuid",
 "vec_map",
]

[[package]]
name = "gilrs-core"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be11a71ac3564f6965839e2ed275bf4fcf5ce16d80d396e1dfdb7b2d80bd587e"
dependencies = [
 "core-foundation 0.10.1",
 "inotify 0.11.0",
 "io-kit-sys",
 "js-sys",
 "libc",
 "libudev-sys",
 "log",
 "nix",
 "uuid",
 "vec_map",
 "wasm-bindgen",
 "web-sys",
 "windows 0.61.3",
]

[[package]]
name = "gimli"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 4649,6 4718,16 @@ dependencies = [
]

[[package]]
name = "io-kit-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
dependencies = [
 "core-foundation-sys",
 "mach2",
]

[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 4821,7 4900,6 @@ dependencies = [
 "eframe",
 "egui 0.32.3",
 "egui_extras",
 "starkingdoms",
]

[[package]]


@@ 4831,6 4909,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"

[[package]]
name = "leafwing-input-manager"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7547ce653e70ffcd6cf1d040ada864e04d293167f237b32c6520ecbf18150b0a"
dependencies = [
 "bevy",
 "dyn-clone",
 "dyn-eq",
 "dyn-hash",
 "itertools 0.14.0",
 "leafwing_input_manager_macros",
 "serde",
 "serde_flexitos",
]

[[package]]
name = "leafwing_input_manager_macros"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2226cb83129176a6c634f2ce0828c2c29896ea0898fc198636f98696b8056890"
dependencies = [
 "proc-macro-crate",
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "libc"
version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 4876,6 4982,16 @@ dependencies = [
]

[[package]]
name = "libudev-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
dependencies = [
 "libc",
 "pkg-config",
]

[[package]]
name = "libz-sys"
version = "1.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 4954,6 5070,15 @@ dependencies = [
]

[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
 "libc",
]

[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 5045,6 5170,16 @@ dependencies = [
]

[[package]]
name = "microlp"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d1790c73b93164ff65868f63164497cb32339458a9297e17e212d91df62258"
dependencies = [
 "log",
 "sprs",
]

[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 5214,6 5349,21 @@ dependencies = [
]

[[package]]
name = "ndarray"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c7c9125e8f6f10c9da3aad044cc918cf8784fa34de857b1aa68038eb05a50a9"
dependencies = [
 "matrixmultiply",
 "num-complex",
 "num-integer",
 "num-traits",
 "portable-atomic",
 "portable-atomic-util",
 "rawpointer",
]

[[package]]
name = "ndk"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 7008,6 7158,16 @@ dependencies = [
]

[[package]]
name = "serde_flexitos"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3323d093d7597660758b742dd7a1525539613f6182b306a4e1dd6e01a89bada9"
dependencies = [
 "erased-serde",
 "serde",
]

[[package]]
name = "serde_ignored"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 7301,6 7461,18 @@ dependencies = [
]

[[package]]
name = "sprs"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dca58a33be2188d4edc71534f8bafa826e787cc28ca1c47f31be3423f0d6e55"
dependencies = [
 "ndarray",
 "num-complex",
 "num-traits",
 "smallvec",
]

[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 7329,6 7501,8 @@ dependencies = [
 "console_error_panic_hook",
 "ctrlc",
 "getrandom 0.3.4",
 "leafwing-input-manager",
 "microlp",
 "ordered-float 5.1.0",
 "pico-args",
 "rand 0.9.2",


@@ 8258,6 8432,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"

[[package]]
name = "vec_map"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"

[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"

M crates/launcher/Cargo.toml => crates/launcher/Cargo.toml +1 -1
@@ 7,4 7,4 @@ edition = "2024"
eframe = "0.32"
egui = "0.32"
egui_extras = { version = "0.32", features = ["all_loaders"] }
starkingdoms = { path = "../unified", features = ["native"] }
\ No newline at end of file
#starkingdoms = { path = "../unified", features = ["native"] }
\ No newline at end of file

M crates/unified/Cargo.toml => crates/unified/Cargo.toml +9 -2
@@ 31,7 31,8 @@ bevy = { version = "0.17", default-features = false, features = [
    "bevy_anti_alias",
    "bevy_sprite_render",
    "bevy_ui_render",
    "zstd_rust"
    "zstd_rust",
    "debug"
] }

avian2d = { version = "0.4", default-features = false, features = [


@@ 73,6 74,9 @@ pico-args = "0.5"

bevy_egui = { version = "0.38", optional = true }

leafwing-input-manager = { version = "0.19", optional = true }
microlp = { version = "0.2", optional = true }

[build-dependencies]
built = { version = "0.8", features = ["git2", "chrono"] }



@@ 97,4 101,7 @@ wasm = ["getrandom/wasm_js", "bevy/webgl2"]

particle_editor = ["bevy_egui"]
server = ["aeronet_websocket/server", "aeronet_replicon/server"]
client = []
\ No newline at end of file
client = [
    "leafwing-input-manager",
    "microlp"
]
\ No newline at end of file

M crates/unified/assets/config/parts/hearty.part.toml => crates/unified/assets/config/parts/hearty.part.toml +17 -2
@@ 10,8 10,23 @@ mass = 100

[[thruster]]
id = "bottom left"
apply_force_at_local = [ -50.0, -50.0 ]
thrust_vector = [ 0.0, 25.0 ]
apply_force_at_local = [ -25.0, -25.0 ]
thrust_vector = [ 0.0, 20.0 ]

[[thruster]]
id = "bottom right"
apply_force_at_local = [ 25.0, -25.0 ]
thrust_vector = [ 0.0, 20.0 ]

[[thruster]]
id = "top left"
apply_force_at_local = [ -25.0, 25.0 ]
thrust_vector = [ 0.0, -20.0 ]

[[thruster]]
id = "top right"
apply_force_at_local = [ 25.0, 25.0 ]
thrust_vector = [ 0.0, -20.0 ]

[[joint]]
id = "Top"

M crates/unified/assets/config/parts/housing.part.toml => crates/unified/assets/config/parts/housing.part.toml +4 -0
@@ 8,6 8,10 @@ width = 50
height = 50
mass = 50

[[thruster]]
id = "primary"
thrust_vector = [ 0.0, 50.0 ]

[[joint]]
id = "Top"
target = { translation = [ 0.0, 55.0, 0.0 ], rotation = 0.0 }

M crates/unified/src/attachment.rs => crates/unified/src/attachment.rs +1 -1
@@ 7,7 7,7 @@ use std::ops::Deref;
/// The primary component for a ship structure
pub struct Ship;

#[derive(Component, Serialize, Deserialize, MapEntities)]
#[derive(Component, Serialize, Deserialize, MapEntities, Deref)]
#[relationship_target(relationship = PartInShip, linked_spawn)]
pub struct Parts(#[entities] Vec<Entity>);


M crates/unified/src/client/input/mod.rs => crates/unified/src/client/input/mod.rs +34 -2
@@ 1,16 1,48 @@
pub mod util;

use bevy::app::{App, Update};
use bevy::window::PrimaryWindow;
use crate::ecs::MainCamera;
use leafwing_input_manager::Actionlike;
use leafwing_input_manager::input_map::InputMap;
use leafwing_input_manager::prelude::{ActionState, ButtonlikeChord};
use crate::ecs::{MainCamera, Me};
use crate::prelude::*;

#[derive(Actionlike, PartialEq, Eq, Hash, Clone, Copy, Debug, Reflect)]
pub enum ClientAction {
    ThrustForward,
    ThrustBackward,
    ThrustRight,
    ThrustLeft,
    TorqueCw,
    TorqueCcw
}

pub fn input_plugin(app: &mut App) {
    app
        .insert_resource(CursorWorldCoordinates(None))
        .insert_resource(InputMap::new([
            (ClientAction::ThrustForward, KeyCode::KeyW),
            (ClientAction::ThrustForward, KeyCode::ArrowUp),

            (ClientAction::ThrustBackward, KeyCode::KeyS),
            (ClientAction::ThrustBackward, KeyCode::ArrowDown),

            (ClientAction::ThrustLeft, KeyCode::KeyQ),
            // TODO: Shift+left
            (ClientAction::ThrustRight, KeyCode::KeyE),
            // TODO: Shift+right

            (ClientAction::TorqueCw, KeyCode::KeyD),
            (ClientAction::TorqueCw, KeyCode::ArrowRight),

            (ClientAction::TorqueCcw, KeyCode::KeyA),
            (ClientAction::TorqueCcw, KeyCode::ArrowLeft),
        ]))
        .init_resource::<ActionState<ClientAction>>()
        .add_systems(Update, update_cursor_position);
}


#[derive(Resource, Default)]
pub struct CursorWorldCoordinates(pub Option<Vec2>);


A crates/unified/src/client/input/util.rs => crates/unified/src/client/input/util.rs +11 -0
@@ 0,0 1,11 @@
use leafwing_input_manager::Actionlike;
use leafwing_input_manager::prelude::ActionState;

pub trait ActionStateExt<A> {
    fn button_changed(&self, a: &A) -> bool;
}
impl<A: Actionlike> ActionStateExt<A> for ActionState<A> {
    fn button_changed(&self, a: &A) -> bool {
        self.just_pressed(a) || self.just_released(a)
    }
}
\ No newline at end of file

M crates/unified/src/client/key_input.rs => crates/unified/src/client/key_input.rs +6 -26
@@ 8,9 8,10 @@ use bevy::{
};
use std::ops::Deref;
use crate::client::ship::attachment::AttachmentDebugRes;
use crate::client::ship::thrusters::ThrusterDebugRes;

pub fn key_input_plugin(app: &mut App) {
    app.add_systems(Update, directional_keys)
    app
        .add_systems(Update, debug_render_keybind)
        .init_resource::<PhysicsDebugRes>();
}


@@ 30,6 31,7 @@ fn debug_render_keybind(
    keys: Res<ButtonInput<KeyCode>>,
    mut picking_debug_mode: ResMut<DebugPickingMode>,
    mut attachment_debug: ResMut<AttachmentDebugRes>,
    mut thruster_debug: ResMut<ThrusterDebugRes>,
) {
    if keys.just_pressed(KeyCode::F4) {
        *picking_debug_mode = DebugPickingMode::Noisy;


@@ 37,31 39,9 @@ fn debug_render_keybind(
    if keys.just_pressed(KeyCode::F5) {
        attachment_debug.0 = !attachment_debug.0;
    }
}

fn directional_keys(keys: Res<ButtonInput<KeyCode>>, mut thrust_event: MessageWriter<ThrustEvent>) {
    if keys.just_pressed(KeyCode::KeyW) || keys.just_pressed(KeyCode::ArrowUp) {
        thrust_event.write(ThrustEvent::Up(true));
    } else if keys.just_released(KeyCode::KeyW) || keys.just_released(KeyCode::ArrowUp) {
        thrust_event.write(ThrustEvent::Up(false));
    }

    if keys.just_pressed(KeyCode::KeyS) || keys.just_pressed(KeyCode::ArrowDown) {
        thrust_event.write(ThrustEvent::Down(true));
    } else if keys.just_released(KeyCode::KeyS) || keys.just_released(KeyCode::ArrowDown) {
        thrust_event.write(ThrustEvent::Down(false));
    }

    if keys.just_pressed(KeyCode::KeyA) || keys.just_pressed(KeyCode::ArrowLeft) {
        thrust_event.write(ThrustEvent::Left(true));
    } else if keys.just_released(KeyCode::KeyA) || keys.just_released(KeyCode::ArrowLeft) {
        thrust_event.write(ThrustEvent::Left(false));
    }

    if keys.just_pressed(KeyCode::KeyD) || keys.just_pressed(KeyCode::ArrowRight) {
        thrust_event.write(ThrustEvent::Right(true));
    } else if keys.just_released(KeyCode::KeyD) || keys.just_released(KeyCode::ArrowRight) {
        thrust_event.write(ThrustEvent::Right(false));
    if keys.just_pressed(KeyCode::F6) {
        thruster_debug.0 = !thruster_debug.0;
    }
}



M crates/unified/src/client/mod.rs => crates/unified/src/client/mod.rs +3 -2
@@ 22,8 22,8 @@ pub mod starfield;
pub mod ui;
pub mod zoom;
pub mod ship;
mod rendering;
mod input;
pub mod rendering;
pub mod input;

pub struct ClientPlugin {
    pub server: Option<String>,


@@ 43,6 43,7 @@ impl Plugin for ClientPlugin {
            })
            .add_plugins(rendering::render_plugin)
            .add_plugins(input::input_plugin)
            .add_plugins(ship::thrusters::client_thrusters_plugin)
            .add_systems(Update, find_me)
            .add_systems(Update, net::set_config)
            .add_plugins((incoming_planets_plugin, indicators_plugin))

M crates/unified/src/client/ship/mod.rs => crates/unified/src/client/ship/mod.rs +2 -1
@@ 1,1 1,2 @@
pub mod attachment;
\ No newline at end of file
pub mod attachment;
pub mod thrusters;
\ No newline at end of file

A crates/unified/src/client/ship/thrusters.rs => crates/unified/src/client/ship/thrusters.rs +204 -0
@@ 0,0 1,204 @@
use std::collections::BTreeSet;
use std::time::Instant;
use bevy::app::App;
use bevy::color::palettes::basic::WHITE;
use bevy::color::palettes::css::LIMEGREEN;
use bevy::math::Vec3Swizzles;
use leafwing_input_manager::prelude::ActionState;
use microlp::{OptimizationDirection, Problem};
use crate::attachment::Parts;
use crate::client::input::ClientAction;
use crate::ecs::thruster::{PartThrusters, Thruster, ThrusterOfPart};
use crate::prelude::*;
use crate::client::input::util::ActionStateExt;
use crate::ecs::Me;
use crate::thrust::ThrustSolution;

pub fn client_thrusters_plugin(app: &mut App) {
    app
        .insert_resource(ThrusterDebugRes(false))
        .insert_resource(ThrustSolution {
            thrusters_on: BTreeSet::default()
        })
        .add_systems(Update, draw_thruster_debug)
        .add_systems(Update, solve_thrust);
}

#[derive(Resource, Deref)]
pub struct ThrusterDebugRes(pub bool);

fn draw_thruster_debug(
    thruster_debug_res: Res<ThrusterDebugRes>,
    thrusters: Query<(&Thruster, Entity, &GlobalTransform)>,
    thrust_solution: Res<ThrustSolution>,
    mut gizmos: Gizmos,
) {
    if !thruster_debug_res.0 { return };
    for thruster in thrusters {
        // Draw white if it's just a thruster, bright green if it's in the current thrust solution
        let color = if thrust_solution.thrusters_on.contains(&thruster.1) {
            LIMEGREEN
        } else {
            WHITE
        };
        gizmos.arrow_2d(
            thruster.2.translation().xy(),
            thruster.2.translation().xy() + thruster.2.rotation().mul_vec3(thruster.0.thrust_vector.extend(0.0)).xy(),
            color
        );
    }
}

fn solve_thrust(
    me: Query<(Option<&Parts>, &GlobalTransform, Entity), With<Me>>,
    parts: Query<&PartThrusters>,
    thrusters: Query<(&Thruster, &GlobalTransform)>,
    input: Res<ActionState<ClientAction>>,
    mut solution: ResMut<ThrustSolution>,
) {
    if !(
        input.button_changed(&ClientAction::ThrustForward)
            || input.button_changed(&ClientAction::ThrustBackward)
            || input.button_changed(&ClientAction::TorqueCw)
            || input.button_changed(&ClientAction::TorqueCcw)
            || input.button_changed(&ClientAction::ThrustRight)
            || input.button_changed(&ClientAction::ThrustLeft)
    ) { return; /* no changes, existing thrust solution is valid */ }

    debug!("input changed, recalculating thrust solution");
    let start = Instant::now();

    // determine our target vector:
    // unit vector in the intended direction of movement

    // Z-axis torque: this cursed thing is apparently standard
    // +Z == counterclockwise/ccw
    // -Z == clockwise/cw

    let mut target_unit_vector = Vec3::ZERO;

    if input.pressed(&ClientAction::ThrustForward) {
        target_unit_vector += Vec3::new(0.0, -1.0, 0.0);
    }
    if input.pressed(&ClientAction::ThrustBackward) {
        target_unit_vector += Vec3::new(0.0, 1.0, 0.0);
    }
    if input.pressed(&ClientAction::ThrustRight) {
        target_unit_vector += Vec3::new(-1.0, 0.0, 0.0);
    }
    if input.pressed(&ClientAction::ThrustLeft) {
        target_unit_vector += Vec3::new(1.0, 0.0, 0.0);
    }
    if input.pressed(&ClientAction::TorqueCw) {
        target_unit_vector += Vec3::new(0.0, 0.0, -1.0);
    }
    if input.pressed(&ClientAction::TorqueCcw) {
        target_unit_vector += Vec3::new(0.0, 0.0, 1.0);
    }

    if target_unit_vector == Vec3::ZERO {
        debug!("no buttons are pressed; zeroing thrust solution");
        solution.thrusters_on.clear();
        debug!("solved thrust in {}ms", start.elapsed().as_millis());
        return;
    }

    let Ok((our_parts, hearty_transform, hearty)) = me.single() else {
        error!("could not solve for thrust: hearty does not exist?");
        error!("failed to solve for thrust after {}ms", start.elapsed().as_millis());
        return;
    };
    let mut all_parts = vec![hearty];
    if let Some(parts) = our_parts {
        all_parts.extend(parts.iter());
    }

    // collect all thrusters on our ship, and figure out their thrust vectors
    let mut all_thrusters = vec![];

    for part in &all_parts {
        let Ok(part_thrusters) = parts.get(*part) else {
            warn!("issue while solving for thrust: part {:?} has no thrusters? skipping...", *part);
            continue;
        };
        for thruster_id in &**part_thrusters {
            let Ok((thruster, thruster_transform)) = thrusters.get(*thruster_id) else {
                warn!("issue while solving for thrust: thruster {:?} of part {:?} does not exist? skipping...", *thruster_id, *part);
                continue;
            };

            // determine the thruster force in world space
            let thruster_vector = thruster_transform.rotation().mul_vec3(thruster.thrust_vector.extend(0.0)).xy();

            // determine our xy offset from hearty
            let relative_translation = thruster_transform.translation().xy() - hearty_transform.translation().xy();
            // determine our rotational offset from hearty
            let relative_rotation = thruster_transform.rotation() * -hearty_transform.rotation();

            let thruster_torque = relative_translation.extend(0.0).cross(thruster_vector.extend(0.0)).z;

            // magically assemble the vector!
            let target_vector = thruster_vector.extend(thruster_torque);

            all_thrusters.push((thruster_id, target_vector));
        }
    }

    // calculate thrust and torque values
    debug!("found {} thrusters, computing coefficients", all_thrusters.len());

    let coefficients = all_thrusters.iter()
        .map(|u| target_unit_vector.dot(u.1))
        .collect::<Vec<_>>();

    debug!("preparing model");
    let mut problem = Problem::new(OptimizationDirection::Maximize);

    // add variables to problem
    let variables = coefficients.iter()
        .map(|u| problem.add_binary_var(*u as f64))
        .collect::<Vec<_>>();

    debug!("prepared {} variables; solving", variables.len());

    let ssolution = match problem.solve() {
        Ok(soln) => soln,
        Err(e) => {
            match e {
                microlp::Error::Infeasible => {
                    error!("failed to solve for thrust: constraints cannot be satisfied");
                    error!("failed to solve for thrust after {}ms", start.elapsed().as_millis());
                    return;
                },
                microlp::Error::Unbounded => {
                    error!("failed to solve for thrust: system is unbounded");
                    error!("failed to solve for thrust after {}ms", start.elapsed().as_millis());
                    return;
                },
                microlp::Error::InternalError(e) => {
                    error!("failed to solve for thrust: solver encountered internal error: {e}");
                    error!("failed to solve for thrust after {}ms", start.elapsed().as_millis());
                    return;
                }
            }
        }
    };

    debug!("found thrust solution!");
    debug!("solution alignment (higher is better): {}", ssolution.objective());

    let mut new_soln = ThrustSolution {
        thrusters_on: BTreeSet::default()
    };

    for thruster in all_thrusters.iter().enumerate() {
        debug!("solution: thruster #{} ({:?}): {}", thruster.0, thruster.1.0, ssolution.var_value_rounded(variables[thruster.0]));
        if ssolution.var_value_rounded(variables[thruster.0]) == 1.0 {
            new_soln.thrusters_on.insert(*thruster.1.0);
        }
    }

    debug!("found thrust solution in {}ms", start.elapsed().as_millis());
    *solution = new_soln;
    return;
}
\ No newline at end of file

M crates/unified/src/client_plugins.rs => crates/unified/src/client_plugins.rs +2 -0
@@ 6,6 6,7 @@ use bevy::dev_tools::picking_debug::DebugPickingPlugin;
use bevy::ecs::schedule::ScheduleLabel;
use crate::prelude::*;
use bevy::ui::UiPlugin;
use leafwing_input_manager::plugin::InputManagerPlugin;

pub struct ClientPluginGroup {
    pub server: Option<String>,


@@ 21,6 22,7 @@ impl PluginGroup for ClientPluginGroup {
                server: self.server,
            })
            .add(UiPlugin)
            .add(InputManagerPlugin::<crate::client::input::ClientAction>::default())
    }
}


M crates/unified/src/main.rs => crates/unified/src/main.rs +1 -0
@@ 42,6 42,7 @@ pub mod prelude;
pub mod wasm_entrypoint;
mod cli;
mod build_meta;
mod thrust;

use std::str::FromStr;
#[cfg(target_arch = "wasm32")]

M crates/unified/src/server/mod.rs => crates/unified/src/server/mod.rs +0 -1
@@ 5,7 5,6 @@ pub mod planets;
pub mod player;
mod system_sets;
mod world_config;
mod thruster;

use crate::server::earth_parts::spawn_parts_plugin;
use crate::server::gravity::newtonian_gravity_plugin;

D crates/unified/src/server/thruster/mod.rs => crates/unified/src/server/thruster/mod.rs +0 -3
@@ 1,3 0,0 @@
//! # Thruster solver
//! Given a ship and the desired target vector, solve for the combination of thrusters
//! that will produce as close to the desired target as possible.
\ No newline at end of file

A crates/unified/src/thrust.rs => crates/unified/src/thrust.rs +11 -0
@@ 0,0 1,11 @@
use std::collections::BTreeSet;
use bevy::prelude::{Entity, Resource};
use serde::{Deserialize, Serialize};

/// A thrust solution, found by the thrust solver on the client.
/// `thrusters_on` is the set of thrusters that should be on.
/// Any thrusters not in this set should be off.
#[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Clone, Resource)]
pub struct ThrustSolution {
    pub thrusters_on: BTreeSet<Entity>
}
\ No newline at end of file

D rust-toolchain.toml => rust-toolchain.toml +0 -2
@@ 1,2 0,0 @@
[toolchain]
channel = "nightly"
\ No newline at end of file