Something went wrong. Try again.
Data management tools for playing BattleTech
Something went wrong. Try again.
8.0 kB · 234 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235//! 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<String>, model: impl Into<String>, 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<ForceUnit>,}
impl Force { pub fn new(units: Vec<ForceUnit>) -> 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")); }}