//! A force: units, the pilots in them, and what the whole thing is worth. //! //! This is the layer [`forces`] needs — a roster is what a player assembles //! and what deploys when a match starts — and it is deliberately arithmetic //! rather than rules. Every figure it works from is one helm already has. //! //! No I/O, by design. The same code has to run in the browser while somebody //! edits a force and again on the server that believes the answer, so //! `scripts/check-boundaries.sh` keeps this crate free of anything that would //! stop it compiling to wasm. //! //! [`forces`]: https://tangled.org/lance.blue/headquarters mod c3; mod formation; mod pilot; pub use c3::{Linked, c3_bonuses, c3_bonuses_linked}; pub use formation::{Blueprint, Carried, Grouping, Member, Requirement, Rule, Verdict}; pub use pilot::{ Pilot, adjusted_battle_value, adjusted_battle_value_in_force, bv_skill_multiplier, }; /// One unit in a force, with the crew flying it. /// /// Identified by chassis and model as two fields, because that is what a /// `.mul` records and therefore what an export and a `blue.lance.force` /// record have to carry. The display name is not a key: it folds a Clan /// design's second name in as `chassis (clanname) model`. #[derive(Debug, Clone, PartialEq)] pub struct ForceUnit { pub chassis: String, pub model: String, /// The design's own battle value, as flown by a regular crew. pub base_battle_value: i64, pub pilot: Pilot, } impl ForceUnit { pub fn new(chassis: impl Into, model: impl Into, base: i64) -> Self { ForceUnit { chassis: chassis.into(), model: model.into(), base_battle_value: base, pilot: Pilot::default(), } } pub fn with_pilot(mut self, pilot: Pilot) -> Self { self.pilot = pilot; self } /// Battle value as this crew flies it. pub fn battle_value(&self) -> i64 { adjusted_battle_value(self.base_battle_value, self.pilot) } /// `chassis model`, for display only — never as a key. pub fn display_name(&self) -> String { if self.model.trim().is_empty() { self.chassis.clone() } else { format!("{} {}", self.chassis.trim(), self.model.trim()) } } } /// A roster. #[derive(Debug, Clone, Default, PartialEq)] pub struct Force { pub units: Vec, } impl Force { pub fn new(units: Vec) -> Self { Force { units } } pub fn len(&self) -> usize { self.units.len() } pub fn is_empty(&self) -> bool { self.units.is_empty() } /// What the force is worth as crewed. /// /// The sum of per-unit values, each already rounded, because MegaMek's /// battle value is an integer per entity before anything adds it up. /// Rounding the total instead would differ by a point or two, which is /// exactly the kind of disagreement that is painful to chase later. pub fn battle_value(&self) -> i64 { self.units.iter().map(ForceUnit::battle_value).sum() } /// What the force would be worth flown by regular crews, which is what a /// catalogue figure means. pub fn base_battle_value(&self) -> i64 { self.units.iter().map(|u| u.base_battle_value).sum() } /// How far the crews move the total, positive when they are better than /// regular. Worth reporting separately: a force can be over a budget /// because of its units or because of its pilots, and those are different /// problems to fix. pub fn skill_adjustment(&self) -> i64 { self.battle_value() - self.base_battle_value() } /// Whether the force lands inside a battle value budget. /// /// A range rather than a target, because generating *to* a number /// produces contorted forces and landing *within a band* does not. pub fn within_budget(&self, min: i64, max: i64) -> bool { (min..=max).contains(&self.battle_value()) } /// Distinct chassis in the force, so "four Atlases" is detectable. pub fn distinct_chassis(&self) -> usize { let mut seen: Vec<&str> = self.units.iter().map(|u| u.chassis.as_str()).collect(); seen.sort_unstable(); seen.dedup(); seen.len() } } #[cfg(test)] mod tests { use super::*; fn lance() -> Force { Force::new(vec![ ForceUnit::new("Atlas", "AS7-D", 1897), ForceUnit::new("Awesome", "AWS-8Q", 1605), ForceUnit::new("Locust", "LCT-1V", 432), ForceUnit::new("Jenner", "JR7-D", 875), ]) } #[test] fn a_force_of_regular_crews_is_just_the_sum() { let f = lance(); assert_eq!(f.len(), 4); assert_eq!(f.base_battle_value(), 1897 + 1605 + 432 + 875); assert_eq!(f.battle_value(), f.base_battle_value()); assert_eq!(f.skill_adjustment(), 0); } // The point of the whole crate: the same four Meks are worth substantially // more in veteran hands, so a budget means nothing without the crews. #[test] fn better_crews_raise_what_the_same_force_is_worth() { let mut f = lance(); for u in &mut f.units { u.pilot = Pilot::new(3, 4); } assert!(f.battle_value() > f.base_battle_value()); assert!(f.skill_adjustment() > 0); // 1.32 across the board, rounded per unit. assert_eq!(f.battle_value(), 2504 + 2119 + 570 + 1155); } #[test] fn worse_crews_lower_it() { let mut f = lance(); for u in &mut f.units { u.pilot = Pilot::new(5, 6); } assert!(f.battle_value() < f.base_battle_value()); assert!(f.skill_adjustment() < 0); } // Rounding per unit and rounding the total are not the same number, and // MegaMek does the former. #[test] fn rounding_is_per_unit_not_on_the_total() { // Three units at 5 BV with a 1.10 crew: each is 5.5, rounding to 6, so // 18. Rounding the total instead would be round(16.5) = 17. let f = Force::new(vec![ ForceUnit::new("A", "1", 5).with_pilot(Pilot::new(4, 4)), ForceUnit::new("B", "1", 5).with_pilot(Pilot::new(4, 4)), ForceUnit::new("C", "1", 5).with_pilot(Pilot::new(4, 4)), ]); assert_eq!(f.battle_value(), 18); } #[test] fn crews_can_differ_within_one_force() { let f = Force::new(vec![ ForceUnit::new("Atlas", "AS7-D", 1897).with_pilot(Pilot::new(3, 4)), ForceUnit::new("Locust", "LCT-1V", 432).with_pilot(Pilot::new(5, 6)), ]); assert_eq!(f.battle_value(), 2504 + 372); } #[test] fn a_budget_is_a_band_and_both_ends_are_inclusive() { let f = lance(); let bv = f.battle_value(); assert!(f.within_budget(bv, bv)); assert!(f.within_budget(bv - 100, bv + 100)); assert!(!f.within_budget(bv + 1, bv + 100)); assert!(!f.within_budget(0, bv - 1)); } #[test] fn duplicate_chassis_are_countable() { assert_eq!(lance().distinct_chassis(), 4); let boring = Force::new(vec![ ForceUnit::new("Atlas", "AS7-D", 1897), ForceUnit::new("Atlas", "AS7-K", 1900), ]); assert_eq!(boring.len(), 2); assert_eq!(boring.distinct_chassis(), 1); } #[test] fn an_empty_force_is_worth_nothing_rather_than_panicking() { let f = Force::default(); assert!(f.is_empty()); assert_eq!(f.battle_value(), 0); assert_eq!(f.distinct_chassis(), 0); } #[test] fn display_name_is_not_the_key() { let u = ForceUnit::new("Atlas", "AS7-D", 1897); assert_eq!(u.display_name(), "Atlas AS7-D"); // The parts stay addressable, which is what a .mul export needs. assert_eq!((u.chassis.as_str(), u.model.as_str()), ("Atlas", "AS7-D")); } }