diff --git a/crates/helm-cli/src/force.rs b/crates/helm-cli/src/force.rs index e2349c4..fb0d6ad 100644 --- a/crates/helm-cli/src/force.rs +++ b/crates/helm-cli/src/force.rs @@ -300,6 +300,50 @@ fn print_attribution( /// who is flying it, and which force it belongs to. What it does not carry is /// the designs, which is why this needs the library as well - an entity names /// a design by chassis and model and says nothing about what is in it. +/// The force's firing, drawn as a line of blocks. +/// +/// The interesting shape is a force's rather than a machine's: four designs +/// that peak in four different places cover a board between them, and four +/// that peak in the same place leave a hole where the fifth should be. Each +/// unit is counted as its own crew flies it, so a veteran lance lands more +/// than the library's own figures say. +fn print_curve(force: &helm_force::Force) { + let curve = force.curve(); + let Some((best, peak)) = force.best_range() else { + return; + }; + let most = curve.iter().copied().max().unwrap_or(0).max(1); + + println!(); + println!("firepower, hex by hex - {peak:.1} at {best} hexes"); + // Eight rows of blocks, which is enough to see a shape and few enough to + // read in a terminal beside the table above it. + const ROWS: i64 = 8; + for row in (0..ROWS).rev() { + let floor = most * row / ROWS; + let line: String = curve + .iter() + .map(|damage| if *damage > floor { "\u{2588}" } else { " " }) + .collect(); + println!(" {line}"); + } + // A tick every six hexes, and the numbers under them. + let ticks: String = (1..=helm_core::REACH) + .map(|hex| if hex % 6 == 0 { '\u{252c}' } else { '\u{2500}' }) + .collect(); + let mut labels = vec![b' '; helm_core::REACH as usize]; + for hex in (6..=helm_core::REACH).step_by(6) { + let text = hex.to_string(); + let start = (hex as usize).saturating_sub(text.len()); + labels[start..start + text.len()].copy_from_slice(text.as_bytes()); + } + println!(" {ticks}"); + println!( + " {} hexes", + String::from_utf8(labels).unwrap_or_default().trim_end() + ); +} + pub(crate) fn force(args: &[String]) -> Result<(), String> { let path = args .first() @@ -458,6 +502,24 @@ pub(crate) fn force(args: &[String]) -> Result<(), String> { if networked > 0 { println!("({networked} units on a C3 network, paid a share of it)"); } + + // Built here rather than in the loop above, because a curve wants the + // design and the crew together and the table wants neither. + let force = helm_force::Force::new( + machines + .iter() + .copied() + .filter_map(|entity| { + let unit = find(&entity.chassis, &entity.model)?; + let curve = helm_core::firepower_flown_by(unit, &catalogue, entity.gunnery as i64); + Some( + helm_force::ForceUnit::new(&entity.chassis, &entity.model, 0) + .firing(curve.curve), + ) + }) + .collect(), + ); + print_curve(&force); if missing > 0 { println!("({missing} not scored)"); } diff --git a/crates/helm-core/src/expected.rs b/crates/helm-core/src/expected.rs index 1e1ed67..8b58728 100644 --- a/crates/helm-core/src/expected.rs +++ b/crates/helm-core/src/expected.rs @@ -262,6 +262,7 @@ fn to_hit( profile: &Profile, range: i64, targeting_computer: bool, + gunnery: i64, ) -> Option { if profile.long <= 0 || range > profile.long { return None; @@ -293,12 +294,18 @@ fn to_hit( 0 }; - Some(GUNNERY + bracket + too_close + carried + computer) + Some(gunnery + bracket + too_close + carried + computer) } /// What one weapon can expect to land at this range, fired the best way it /// can be. -fn expected(weapon: &EquipmentEntry, range: i64, troopers: i64, computer: bool) -> f64 { +fn expected( + weapon: &EquipmentEntry, + range: i64, + troopers: i64, + computer: bool, + gunnery: i64, +) -> f64 { if NOT_FIREPOWER.iter().any(|flag| weapon.has_flag(flag)) { return 0.0; } @@ -309,7 +316,9 @@ fn expected(weapon: &EquipmentEntry, range: i64, troopers: i64, computer: bool) let legal: Vec = all .iter() .filter(|p| all.len() == 1 || range > p.minimum) - .filter_map(|p| to_hit(weapon, p, range, computer).map(|needed| p.damage * chance(needed))) + .filter_map(|p| { + to_hit(weapon, p, range, computer, gunnery).map(|needed| p.damage * chance(needed)) + }) .collect(); legal.into_iter().fold(0.0, f64::max) } @@ -329,6 +338,7 @@ struct Firing<'a> { weapons: Vec<(&'a EquipmentEntry, i64)>, troopers: i64, computer: bool, + gunnery: i64, } impl<'a> Firing<'a> { @@ -354,6 +364,17 @@ impl<'a> Firing<'a> { } } + let troopers = unit.troopers().unwrap_or(1); + // A battle armour file describes one suit and a squad fights as four, + // five or six of them, each carrying the same guns. An infantry + // weapon on an anti-personnel mount is not multiplied here: its + // damage is already per trooper - see `profiles`. + let suits = if unit.is_battle_armor() { + troopers.max(1) + } else { + 1 + }; + let weapons = items .into_iter() .filter(|(entry, _)| { @@ -365,17 +386,29 @@ impl<'a> Firing<'a> { .copied() .unwrap_or(0); let forward = count - rear; - (forward > 0).then_some((entry, forward)) + let carried = if entry.infantry_damage.is_some() { + forward + } else { + forward * suits + }; + (carried > 0).then_some((entry, carried)) }) .collect(); Firing { weapons, - troopers: unit.troopers().unwrap_or(1), + troopers, computer, + gunnery: GUNNERY, } } + /// The same guns, in the hands of a crew of this skill. + fn flown_by(mut self, gunnery: i64) -> Self { + self.gunnery = gunnery; + self + } + /// What the design expects to land at this range, everything firing. fn at(&self, range: i64) -> f64 { self.picked(range, &|_| true) @@ -387,7 +420,7 @@ impl<'a> Firing<'a> { .iter() .filter(|(entry, _)| which(entry)) .map(|(entry, count)| { - expected(entry, range, self.troopers, self.computer) * *count as f64 + expected(entry, range, self.troopers, self.computer, self.gunnery) * *count as f64 }) .sum() } @@ -424,7 +457,17 @@ pub struct Firepower { /// Read a design's guns once and answer everything the model knows. pub fn firepower_of(unit: &Unit, catalogue: &Catalogue) -> Firepower { - let guns = Firing::read(unit, catalogue); + firepower_flown_by(unit, catalogue, GUNNERY) +} + +/// The same, for a crew that is not the regular one. +/// +/// A veteran lands more of what a design throws and a green crew less, and +/// the difference is the whole of it: nothing else about the design changes. +/// The library's own figures are all at [`GUNNERY`], so a curve at any other +/// skill is a force's rather than a design's. +pub fn firepower_flown_by(unit: &Unit, catalogue: &Catalogue, gunnery: i64) -> Firepower { + let guns = Firing::read(unit, catalogue).flown_by(gunnery); let curve: Vec = (1..=crate::REACH) .map(|range| (guns.at(range) * 10.0).round() as i64) .collect(); @@ -501,7 +544,7 @@ mod tests { /// What the attacker needs for a weapon fired the only way it can be. fn needed(weapon: &EquipmentEntry, range: i64, computer: bool) -> Option { let all = profiles(weapon, 1); - to_hit(weapon, all.first()?, range, computer) + to_hit(weapon, all.first()?, range, computer, GUNNERY) } fn weapon(name: &str, avg: f64, ranges: (i64, i64, i64), flags: &[&str]) -> EquipmentEntry { @@ -545,7 +588,7 @@ mod tests { let pulse = weapon("Pulse", 9.0, (3, 7, 10), &["F_DIRECT_FIRE", "F_PULSE"]); assert_eq!(needed(&plain, 3, false), Some(4)); assert_eq!(needed(&pulse, 3, false), Some(2)); - assert!(expected(&pulse, 3, 1, false) > expected(&plain, 3, 1, false)); + assert!(expected(&pulse, 3, 1, false, GUNNERY) > expected(&plain, 3, 1, false, GUNNERY)); } /// And the other half of the same complaint: a heavy laser hits hard and @@ -563,8 +606,8 @@ mod tests { // the modifier narrows the gap rather than closing it, which is what // a modifier should do. let plain = weapon("Laser", 9.0, (3, 7, 10), &["F_DIRECT_FIRE"]); - assert!(expected(&heavy, 5, 1, false) > expected(&plain, 5, 1, false)); - assert!(expected(&heavy, 5, 1, false) < 16.0); + assert!(expected(&heavy, 5, 1, false, GUNNERY) > expected(&plain, 5, 1, false, GUNNERY)); + assert!(expected(&heavy, 5, 1, false, GUNNERY) < 16.0); } /// A launcher inside its minimum range is harder to aim, hex by hex, and @@ -576,7 +619,7 @@ mod tests { assert_eq!(needed(&lrm, 7, false), Some(4), "outside the minimum"); assert_eq!(needed(&lrm, 6, false), Some(5), "at the minimum"); assert_eq!(needed(&lrm, 1, false), Some(10), "point blank"); - assert!(expected(&lrm, 1, 1, false) < expected(&lrm, 7, 1, false)); + assert!(expected(&lrm, 1, 1, false, GUNNERY) < expected(&lrm, 7, 1, false, GUNNERY)); } /// MegaMek writes Integer.MIN_VALUE for a weapon with no minimum range, @@ -674,14 +717,14 @@ mod loadings_and_troopers { // At two hexes the long-range loading is inside its minimum, so the // short one is fired: 6 points at 33 in 36. assert!( - (expected(&mml, 2, 1, false) - 5.5).abs() < 0.05, + (expected(&mml, 2, 1, false, GUNNERY) - 5.5).abs() < 0.05, "{}", - expected(&mml, 2, 1, false) + expected(&mml, 2, 1, false, GUNNERY) ); // At fifteen only the long-range loading reaches at all. - assert!((expected(&mml, 15, 1, false) - 1.25).abs() < 0.05); + assert!((expected(&mml, 15, 1, false, GUNNERY) - 1.25).abs() < 0.05); // And past twenty-one neither does. - assert_eq!(expected(&mml, 22, 1, false), 0.0); + assert_eq!(expected(&mml, 22, 1, false, GUNNERY), 0.0); } /// An ATM picks between three rounds the same way. @@ -691,13 +734,13 @@ mod loadings_and_troopers { // High-explosive up close: 12 points, and the other two are inside // their minimum. assert!( - (expected(&atm, 2, 1, false) - 11.0).abs() < 0.05, + (expected(&atm, 2, 1, false, GUNNERY) - 11.0).abs() < 0.05, "{}", - expected(&atm, 2, 1, false) + expected(&atm, 2, 1, false, GUNNERY) ); // Extended range is the only one that reaches twenty. - assert!(expected(&atm, 20, 1, false) > 0.0); - assert_eq!(expected(&atm, 28, 1, false), 0.0); + assert!(expected(&atm, 20, 1, false, GUNNERY) > 0.0); + assert_eq!(expected(&atm, 28, 1, false, GUNNERY), 0.0); } /// A platoon's damage is one trooper's rifle times the platoon. @@ -713,13 +756,13 @@ mod loadings_and_troopers { ..Default::default() }; // Twenty-eight troopers at 0.28 each, landing 33 times in 36. - let one = expected(&rifle, 1, 1, false); - let platoon = expected(&rifle, 1, 28, false); + let one = expected(&rifle, 1, 1, false, GUNNERY); + let platoon = expected(&rifle, 1, 28, false, GUNNERY); assert!((platoon - one * 28.0).abs() < 1e-9); assert!((one - 0.28 * 33.0 / 36.0).abs() < 1e-9); // Its reach is two hexes, doubled and trebled for the brackets. - assert!(expected(&rifle, 6, 28, false) > 0.0); - assert_eq!(expected(&rifle, 7, 28, false), 0.0); + assert!(expected(&rifle, 6, 28, false, GUNNERY) > 0.0); + assert_eq!(expected(&rifle, 7, 28, false, GUNNERY), 0.0); } /// Artillery, tags, anti-missile systems and the rest are not firepower, @@ -738,7 +781,7 @@ mod loadings_and_troopers { long_range: Some(9), ..Default::default() }; - assert_eq!(expected(&odd, 3, 1, false), 0.0, "{flag}"); + assert_eq!(expected(&odd, 3, 1, false, GUNNERY), 0.0, "{flag}"); } } } diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs index 943aeea..1b9d9c4 100644 --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -41,7 +41,8 @@ pub use design::{ StructureKind, }; pub use expected::{ - Firepower, GUNNERY, ammo_share, expected_damage_curve, firepower, firepower_of, one_shot_share, + Firepower, GUNNERY, ammo_share, expected_damage_curve, firepower, firepower_flown_by, + firepower_of, one_shot_share, }; pub use metrics::{ CombatMetrics, FORMATION_RANGES, FORMATION_SINGLE_RANGES, REACH, can_make_anti_mek_attacks, diff --git a/crates/helm-force/src/lib.rs b/crates/helm-force/src/lib.rs index 4fdcb8a..fd7e420 100644 --- a/crates/helm-force/src/lib.rs +++ b/crates/helm-force/src/lib.rs @@ -34,6 +34,13 @@ pub struct ForceUnit { /// The design's own battle value, as flown by a regular crew. pub base_battle_value: i64, pub pilot: Pilot, + /// What this unit expects to land at each hex, in tenths of a point, as + /// this crew flies it. + /// + /// Empty where the caller had no library to work it out from, which is + /// every caller that only wants a battle value. A force's curve is the + /// sum of the ones it has. + pub curve: Vec, } impl ForceUnit { @@ -44,9 +51,16 @@ impl ForceUnit { model: model.into(), base_battle_value: base, pilot: Pilot::default(), + curve: Vec::new(), } } + /// The same unit, with what it expects to land at each hex. + pub fn firing(mut self, curve: Vec) -> Self { + self.curve = curve; + self + } + /// The same unit, flown by this crew. pub fn with_pilot(mut self, pilot: Pilot) -> Self { self.pilot = pilot; @@ -96,6 +110,35 @@ impl Force { self.units.iter().map(ForceUnit::battle_value).sum() } + /// What the whole force expects to land at each hex, in tenths of a + /// point. + /// + /// The interesting shape in a force rather than in a machine: four + /// designs that each peak somewhere different cover a board between them, + /// and four that peak in the same place have a hole where the fifth + /// should be. Units with no curve contribute nothing rather than being + /// counted as zero-damage, which is the same arithmetic and a different + /// claim - see `ForceUnit::curve`. + pub fn curve(&self) -> Vec { + let reach = helm_core::REACH as usize; + let mut total = vec![0i64; reach]; + for unit in &self.units { + for (at, damage) in unit.curve.iter().take(reach).enumerate() { + total[at] += damage; + } + } + total + } + + /// The most the force expects to land at any one range, and where. + /// + /// `None` where no unit in it carries a curve at all. + pub fn best_range(&self) -> Option<(i64, f64)> { + let curve = self.curve(); + let best = curve.iter().copied().enumerate().max_by_key(|(_, d)| *d)?; + (best.1 > 0).then(|| (best.0 as i64 + 1, best.1 as f64 / 10.0)) + } + /// What the force would be worth flown by regular crews, which is what a /// catalogue figure means. pub fn base_battle_value(&self) -> i64 {