Something went wrong. Try again.
Data management tools for playing BattleTech
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799//! What a design can expect to land, hex by hex.//!//! # Why this exists beside `damage_at_range`//!//! [`crate::damage_at_range`] is what a design can *throw*: every shot hits,//! every missile in the salvo lands, and a weapon counts flat from one hex out//! to its long range. That is MegaMek's own figure and the formation rules are//! written against it, so it stays exactly as it is.//!//! It is also the wrong thing to rank designs by. It says a Heavy Large Laser//! is worth sixteen at fifteen hexes, when a heavy laser is +1 to hit and the//! long-range bracket is +4 on top; and it says a Large Pulse Laser is worth//! nine, when the pulse mechanism is -2 and it will land far more often. Rank//! by what is thrown and a heavy laser boat looks like the best machine in the//! library, which is not an opinion anybody who has played holds.//!//! # The fight this assumes//!//! One a design is never actually in, and the same one for every design, which//! is what makes the numbers comparable://!//! * a gunnery 4 crew, which is the regular figure a battle value assumes;//! * open ground, no terrain, nothing in the way;//! * neither machine moving, so no attacker or target movement modifier;//! * every weapon fired, with no heat, no ammunition and no arc to worry//! about.//!//! It is a best case, deliberately. A real to-hit number depends on two//! machines, a map and a turn, none of which a library has.//!//! Heat is not in it either, and that is a division rather than an oversight://! `CombatMetrics::heat_efficiency` is a column of its own, so "hits hard and//! can keep doing it" is a filter with two terms in it rather than one figure//! that has quietly made up its mind.//!//! Nor is whether a launcher has anything to fire. [`crate::damage_at_range`]//! checks, because the formation rules it serves are defined that way; this//! does not, because seven designs in the library declare a field gun and no//! bin for it, and a machine whose paperwork is short a magazine is not a//! machine that cannot shoot.
use crate::{Catalogue, EquipmentEntry, Unit};
/// The crew this assumes: a regular MechWarrior, the same figure battle value/// is computed for.pub const GUNNERY: i64 = 4;
/// What each range bracket adds to a to-hit roll.const SHORT: i64 = 0;const MEDIUM: i64 = 2;const LONG: i64 = 4;
/// One thing about a weapon that moves the to-hit number, and what it moves it/// by.////// A table rather than a chain of `if`s for the same reason the priced items/// are one: these are independent facts, somebody will add another, and a set/// that can be read down is a set that can be checked against a rulebook.struct Modifier { /// The flag MegaMek tells it by. flag: &'static str, /// What it does to the number the attacker needs. to_hit: i64,}
/// Everything about a weapon itself that changes how often it lands.////// Deliberately short. This is a comparison between designs rather than a/// combat model, so what belongs here is what a weapon carries with it/// wherever it goes - not what the ground, the range or the target are doing.const WEAPON_MODIFIERS: &[Modifier] = &[ // The pulse mechanism is the whole reason pulse lasers are worth their // weight, and a figure that ignores it rates them below the plain sort. Modifier { flag: "F_PULSE", to_hit: -2, }, // Clan heavy lasers hit hard and hit rarely. Without this they look like // the best weapons in the game. Modifier { flag: "HEAVY_LASER", to_hit: 1, },];
/// What a targeting computer does for the weapons it can help.const TARGETING_COMPUTER: i64 = -1;
/// Weapons this leaves out, whatever damage MegaMek records for them.////// Each one answers a question this figure is not asking. Artillery is fired/// at a map square from off the board; a TAG marks a target and hurts nobody;/// an anti-missile system shoots down missiles; a taser and a TSEMP shut a/// machine down rather than damaging it; a leg attack is a platoon climbing a/// Mek rather than shooting at one.const NOT_FIREPOWER: &[&str] = &[ "F_ARTILLERY", "F_TAG", "F_AMS", "F_AMS_BAY", "F_TSEMP", "F_TASER", "F_INFANTRY_ATTACK",];
/// One way a launcher can be loaded, and what it does loaded that way.////// An MML fires long-range missiles or short ones and an ATM fires three/// kinds of round, and the ammunition decides the reach as well as the/// damage - which is why MegaMek writes 0/0/0 for an MML's ranges and leaves/// the rest to the bin. `scale` is what a round does against the standard/// one: an ATM's high-explosive round is three points a missile where the/// standard is two.struct Loading { short: i64, medium: i64, long: i64, minimum: i64, scale: f64,}
/// The loadings for a launcher that has them, by the ammunition it takes.////// Ranges from the rulebook rather than from the dump, which states none for/// these. The assumption is written down in TODO.md so somebody can check it.fn loadings(ammo_type: Option<&str>) -> &'static [Loading] { match ammo_type { // Long-range missiles or short, out of the same tube. Some("MML") => &[ Loading { short: 7, medium: 14, long: 21, minimum: 6, scale: 1.0, }, Loading { short: 3, medium: 6, long: 9, minimum: 0, scale: 2.0, }, ], // Standard, high-explosive and extended-range rounds. Some("ATM") => &[ Loading { short: 5, medium: 10, long: 15, minimum: 4, scale: 1.0, }, Loading { short: 3, medium: 6, long: 9, minimum: 0, scale: 1.5, }, Loading { short: 9, medium: 18, long: 27, minimum: 4, scale: 0.5, }, ], _ => &[], }}
/// The chance a 2d6 roll makes a number, out of 36.////// Written out rather than computed: it is eleven numbers and a table can be/// checked against the back of the rulebook at a glance.fn chance(needed: i64) -> f64 { let ways = match needed { i64::MIN..=2 => 36, 3 => 35, 4 => 33, 5 => 30, 6 => 26, 7 => 21, 8 => 15, 9 => 10, 10 => 6, 11 => 3, 12 => 1, _ => 0, }; f64::from(ways) / 36.0}
/// The reach and the damage a weapon has, once its loading is decided.struct Profile { short: i64, medium: i64, long: i64, minimum: i64, damage: f64,}
/// Every way this weapon can be fired, at whatever it is carrying.////// One profile for an ordinary gun. Two or three for a launcher whose/// ammunition decides its reach, and one per trooper's rifle scaled to the/// platoon holding it.fn profiles(weapon: &EquipmentEntry, troopers: i64) -> Vec<Profile> { // A rifle is not a gun bolted to a machine. MegaMek keeps what one // trooper's weapon does and how far it reaches, and the brackets are that // reach doubled and trebled. if let (Some(each), Some(reach)) = (weapon.infantry_damage, weapon.infantry_range) { // A submachine gun's reach is nought, meaning the hex it is fired in. // A curve starts at one hex, so that is where it is counted. let reach = reach.max(1); return vec![Profile { short: reach, medium: reach * 2, long: reach * 3, minimum: 0, damage: each * troopers.max(1) as f64, }]; }
// MegaMek's own average, cluster tables already applied: an LRM 20 is 12 // here rather than a full salvo of 20. // // Sixty-one weapons carry a plain damage figure and no average - machine // guns, most battle armour weapons, the light autocannons - because // `shortAV` is an aerospace field MegaMek does not fill for everything. // A single-shot weapon's average is its damage, so that is what is used, // and only where the figure is a number: MegaMek writes -2 for a cluster // weapon, -3 for a variable one, -4 for special and -5 for artillery. let damage = weapon .average_damage .filter(|d| *d > 0.0) .or_else(|| weapon.damage.map(|d| d as f64).filter(|d| *d > 0.0)) .unwrap_or(0.0); if damage <= 0.0 { return Vec::new(); }
let choices = loadings(weapon.ammo_type.as_deref()); if !choices.is_empty() { return choices .iter() .map(|l| Profile { short: l.short, medium: l.medium, long: l.long, minimum: l.minimum, damage: damage * l.scale, }) .collect(); }
vec![Profile { short: weapon.short_range.unwrap_or(0), medium: weapon.medium_range.unwrap_or(0), long: weapon.long_range.unwrap_or(0), // MegaMek writes Integer.MIN_VALUE for a weapon with no minimum // range, and helm-bridge keeps the field as it found it. minimum: weapon.min_range.filter(|m| *m > 0).unwrap_or(0), damage, }]}
/// What the attacker needs to roll for this profile at this range, or `None`/// where it cannot be fired at all.fn to_hit( weapon: &EquipmentEntry, profile: &Profile, range: i64, targeting_computer: bool, gunnery: i64,) -> Option<i64> { if profile.long <= 0 || range > profile.long { return None; } // Never into the minimum. A loading that cannot be fired here is not // chosen; a weapon with only one is fired anyway, at a penalty. let too_close = if range <= profile.minimum { profile.minimum - range + 1 } else { 0 }; let bracket = match range { r if r <= profile.short => SHORT, r if r <= profile.medium => MEDIUM, _ => LONG, };
let carried: i64 = WEAPON_MODIFIERS .iter() .filter(|m| weapon.has_flag(m.flag)) .map(|m| m.to_hit) .sum();
// A targeting computer helps a weapon that is aimed, which is what // MegaMek's direct-fire flag means. It does nothing for a missile. let computer = if targeting_computer && weapon.has_flag("F_DIRECT_FIRE") { TARGETING_COMPUTER } else { 0 };
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, gunnery: i64,) -> f64 { if NOT_FIREPOWER.iter().any(|flag| weapon.has_flag(flag)) { return 0.0; } let all = profiles(weapon, troopers); // A launcher picks its loading a shot at a time, so the one that lands // most is the one it fires - and a loading it would have to fire into its // own minimum range simply is not chosen. let legal: Vec<f64> = all .iter() .filter(|p| all.len() == 1 || range > p.minimum) .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)}
/// What a design can expect to land at each hex from 1 to [`crate::REACH`], in/// tenths of a point.////// Tenths because the answer is fractional and a chunk of whole numbers is a/// third the size of a chunk of decimals - and a tenth of a point of damage is/// already finer than the question deserves.pub fn expected_damage_curve(unit: &Unit, catalogue: &Catalogue) -> Vec<i64> { firepower_of(unit, catalogue).curve}
/// What a design shoots forward with, and who is holding it.struct Firing<'a> { weapons: Vec<(&'a EquipmentEntry, i64)>, troopers: i64, computer: bool, gunnery: i64,}
impl<'a> Firing<'a> { /// Read the design's guns once, so a curve does not read them two dozen /// times. fn read(unit: &Unit, catalogue: &'a Catalogue) -> Firing<'a> { // The list MegaMek would evaluate, not the mounts alone: a platoon // declares its rifles as `<Primary>` and `<Secondary>` tags rather // than as equipment, and nothing else finds them. let items = crate::equipment_list(unit, catalogue); let computer = items .iter() .any(|(entry, _)| entry.has_flag("F_TARGETING_COMPUTER"));
// Weapons pointed backwards are left out entirely. A Warhammer's rear // laser cannot fire at what its front guns are firing at, and a // figure that adds it in credits the design with a shot it will never // take at the target it is shooting. let mut behind: std::collections::BTreeMap<&str, i64> = std::collections::BTreeMap::new(); for mount in unit.equipment.iter().filter(|m| m.rear) { if let Some(entry) = catalogue.resolve_for(&mount.name, unit.is_clan()) { *behind.entry(entry.internal_name.as_str()).or_default() += 1; } }
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, _)| { (entry.is_weapon() || entry.infantry_damage.is_some()) && !entry.is_ammo() }) .filter_map(|(entry, count)| { let rear = behind .get(entry.internal_name.as_str()) .copied() .unwrap_or(0); let forward = count - rear; let carried = if entry.infantry_damage.is_some() { forward } else { forward * suits }; (carried > 0).then_some((entry, carried)) }) .collect();
Firing { weapons, 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) }
/// The same, counting only the weapons `which` picks out. fn picked(&self, range: i64, which: &dyn Fn(&EquipmentEntry) -> bool) -> f64 { self.weapons .iter() .filter(|(entry, _)| which(entry)) .map(|(entry, count)| { expected(entry, range, self.troopers, self.computer, self.gunnery) * *count as f64 }) .sum() }}
/// How much of what a design can expect to land it can only land once, as a/// percentage.////// A rocket launcher is fired and thrown away. A Longbow LGB-0H carries/// twenty-eight of them and nothing else worth naming, so every figure that/// counts them treats a one-shot alpha as a way of fighting - which is why a/// design like that wants an asterisk beside it rather than a footnote/// somewhere else.////// Measured at the range the design is best at, so a launcher that cannot/// reach is not counted against it. `None` for a design that can expect to/// land nothing anywhere./// Everything the firepower model says about one design.////// Worked out together because reading a design's guns is most of what any of/// it costs, and asking four times what can be asked once doubled the time an/// index takes to build.pub struct Firepower { /// What it expects to land at each hex from 1 to [`crate::REACH`], in /// tenths of a point. pub curve: Vec<i64>, /// The most it expects to land at any one range. pub peak: f64, /// How much of that comes out of a magazine, as a percentage. pub ammo: Option<i64>, /// How much of it can only be landed once, as a percentage. pub one_shot: Option<i64>,}
/// Read a design's guns once and answer everything the model knows.pub fn firepower_of(unit: &Unit, catalogue: &Catalogue) -> Firepower { 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<i64> = (1..=crate::REACH) .map(|range| (guns.at(range) * 10.0).round() as i64) .collect(); let peak = curve.iter().copied().max().unwrap_or(0) as f64 / 10.0;
let best = (1..=crate::REACH).max_by(|a, b| guns.at(*a).total_cmp(&guns.at(*b))); let share = |which: &dyn Fn(&EquipmentEntry) -> bool| -> Option<i64> { let best = best?; let total = guns.at(best); if total <= 0.0 { return None; } Some((guns.picked(best, which) / total * 100.0).round() as i64) };
Firepower { curve, peak, ammo: share(&is_ammo_fed), one_shot: share(&is_one_shot), }}
/// A weapon that has to be fed. An infantry weapon is not: a platoon's rifles/// are carried by the troopers rather than drawn from bins, and they do not/// run dry in a match.fn is_ammo_fed(entry: &EquipmentEntry) -> bool { entry .ammo_type .as_deref() .is_some_and(|kind| kind != "INFANTRY")}
/// A weapon that is fired and thrown away.fn is_one_shot(entry: &EquipmentEntry) -> bool { entry.has_flag("F_ONE_SHOT") || entry.has_flag("F_DOUBLE_ONE_SHOT")}
/// The most a design can expect to land at any one range.////// The peak of its curve, which is what "how hard does this hit" means when/// the question is about a machine rather than about a moment. Nought for a/// design with nothing this can model - see the module docs.pub fn firepower(unit: &Unit, catalogue: &Catalogue) -> f64 { firepower_of(unit, catalogue).peak}
/// How much of what a design can expect to land comes out of a magazine, as a/// percentage.////// Measured at the range it is best at, the same as [`one_shot_share`], so the/// two shares are of the same number. Nought is a design that never needs/// resupply; a hundred is one whose guns are all fed from bins.pub fn ammo_share(unit: &Unit, catalogue: &Catalogue) -> Option<i64> { firepower_of(unit, catalogue).ammo}
/// How much of what a design can expect to land it can only land once, as a/// percentage.////// A rocket launcher is fired and thrown away. A Longbow LGB-0H carries/// twenty-eight of them and nothing else worth naming, so every figure that/// counts them treats a one-shot alpha as a way of fighting - which is why a/// design like that wants an asterisk beside it rather than a footnote/// somewhere else.pub fn one_shot_share(unit: &Unit, catalogue: &Catalogue) -> Option<i64> { firepower_of(unit, catalogue).one_shot}
#[cfg(test)]mod tests { use super::*;
/// What the attacker needs for a weapon fired the only way it can be. fn needed(weapon: &EquipmentEntry, range: i64, computer: bool) -> Option<i64> { let all = profiles(weapon, 1); to_hit(weapon, all.first()?, range, computer, GUNNERY) }
fn weapon(name: &str, avg: f64, ranges: (i64, i64, i64), flags: &[&str]) -> EquipmentEntry { EquipmentEntry { internal_name: name.into(), name: name.into(), classes: vec!["WeaponType".into()], average_damage: Some(avg), short_range: Some(ranges.0), medium_range: Some(ranges.1), long_range: Some(ranges.2), flags: flags.iter().map(|f| format!("WeaponType.{f}")).collect(), ..Default::default() } }
/// One weapon per location, because `mounted_items` counts an item that /// fills several slots of one location once - which is right for a Gauss /// rifle and wrong for a fixture pretending three launchers are three. fn carrying(entries: Vec<EquipmentEntry>, slots: &[&str]) -> (Unit, Catalogue) { let mut unit = Unit { chassis: "Test".into(), model: "1".into(), format_str: "mtf".into(), mass: Some(50.0), ..Default::default() }; for (at, slot) in slots.iter().enumerate() { unit.criticals .insert(format!("Location {at}"), vec![(*slot).to_string()]); } (unit, Catalogue::new(entries)) }
/// The whole point: a gun that lands more often is worth more, and the /// figure says so. A pulse laser needs a 2 at short range where the plain /// one needs a 4. #[test] fn a_pulse_laser_beats_a_plain_one_of_the_same_damage() { let plain = weapon("Laser", 9.0, (3, 7, 10), &["F_DIRECT_FIRE"]); 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, GUNNERY) > expected(&plain, 3, 1, false, GUNNERY)); }
/// And the other half of the same complaint: a heavy laser hits hard and /// rarely, and a figure that ignores the +1 rates it above everything. #[test] fn a_heavy_laser_pays_for_being_hard_to_aim() { let heavy = weapon( "Heavy", 16.0, (5, 10, 15), &["F_DIRECT_FIRE", "HEAVY_LASER"], ); assert_eq!(needed(&heavy, 5, false), Some(5)); // Sixteen points at 30 in 36 is still more than nine at 33 in 36 - // 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, 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 /// this is the modifier that stops a missile boat topping a brawling sort. #[test] fn a_launcher_is_worse_the_closer_the_target_gets() { let mut lrm = weapon("LRM 20", 12.0, (7, 14, 21), &["F_MISSILE"]); lrm.min_range = Some(6); 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, GUNNERY) < expected(&lrm, 7, 1, false, GUNNERY)); }
/// MegaMek writes Integer.MIN_VALUE for a weapon with no minimum range, /// and helm-bridge keeps the field as it found it. Read as a number it /// would make every weapon in the library unmissable. #[test] fn no_minimum_range_is_not_a_very_large_negative_one() { let mut laser = weapon("Laser", 9.0, (3, 7, 10), &["F_DIRECT_FIRE"]); laser.min_range = Some(i64::from(i32::MIN)); assert_eq!(needed(&laser, 1, false), Some(4)); }
/// A targeting computer helps what is aimed and not what is lobbed. #[test] fn a_targeting_computer_helps_direct_fire_alone() { let laser = weapon("Laser", 9.0, (3, 7, 10), &["F_DIRECT_FIRE"]); let lrm = weapon("LRM 20", 12.0, (7, 14, 21), &["F_MISSILE"]); assert_eq!(needed(&laser, 3, true), Some(3)); assert_eq!(needed(&lrm, 7, true), Some(4)); }
/// The asterisk: a design whose damage is a one-time alpha is a different /// proposition from one that can do it every turn. #[test] fn a_rocket_boat_is_mostly_one_shot() { let (unit, catalogue) = carrying( vec![ weapon("RL10", 6.0, (5, 11, 18), &["F_MISSILE", "F_ONE_SHOT"]), weapon("Laser", 5.0, (3, 6, 9), &["F_DIRECT_FIRE"]), ], &["RL10", "RL10", "RL10", "Laser"], ); let share = one_shot_share(&unit, &catalogue).expect("it has weapons"); assert!(share > 70, "{share}");
let (plain, catalogue) = carrying( vec![weapon("Laser", 5.0, (3, 6, 9), &["F_DIRECT_FIRE"])], &["Laser"], ); assert_eq!(one_shot_share(&plain, &catalogue), Some(0)); }
/// A design with nothing to shoot with is not nought percent one-shot. #[test] fn an_unarmed_design_has_no_share_at_all() { let (unit, catalogue) = carrying(vec![], &[]); assert_eq!(one_shot_share(&unit, &catalogue), None); }
/// The curve falls away as the brackets change and stops where the last /// weapon does. #[test] fn the_curve_falls_off_with_the_brackets() { let (unit, catalogue) = carrying( vec![weapon("Laser", 9.0, (3, 7, 10), &["F_DIRECT_FIRE"])], &["Laser"], ); let curve = expected_damage_curve(&unit, &catalogue); assert_eq!(curve.len(), crate::REACH as usize); // 9 damage at 33 in 36, then 26, then 15, then out of range. assert_eq!(curve[2], 83, "short"); assert_eq!(curve[6], 65, "medium"); assert_eq!(curve[9], 38, "long"); assert_eq!(curve[10], 0, "past its reach"); }}
#[cfg(test)]mod loadings_and_troopers { use super::*; use crate::EquipmentEntry;
fn launcher(ammo_type: &str, avg: f64) -> EquipmentEntry { EquipmentEntry { internal_name: format!("{ammo_type} 5"), name: format!("{ammo_type} 5"), classes: vec!["WeaponType".into()], average_damage: Some(avg), ammo_type: Some(ammo_type.into()), // What MegaMek writes for a launcher whose ammunition decides // its reach: nothing at all. short_range: Some(0), medium_range: Some(0), long_range: Some(0), ..Default::default() } }
/// The launcher fires whichever loading lands most, and never one it /// would have to fire into its own minimum range. #[test] fn an_mml_is_short_ranged_up_close_and_long_ranged_far_off() { let mml = launcher("MML", 3.0);
// 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, GUNNERY) - 5.5).abs() < 0.05, "{}", expected(&mml, 2, 1, false, GUNNERY) ); // At fifteen only the long-range loading reaches at all. 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, GUNNERY), 0.0); }
/// An ATM picks between three rounds the same way. #[test] fn an_atm_picks_the_round_that_lands_most() { let atm = launcher("ATM", 8.0); // High-explosive up close: 12 points, and the other two are inside // their minimum. assert!( (expected(&atm, 2, 1, false, GUNNERY) - 11.0).abs() < 0.05, "{}", expected(&atm, 2, 1, false, GUNNERY) ); // Extended range is the only one that reaches twenty. 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. #[test] fn a_rifle_is_worth_what_the_platoon_carries() { let rifle = EquipmentEntry { internal_name: "Laser Rifle".into(), name: "Laser Rifle".into(), classes: vec!["WeaponType".into()], flags: vec!["WeaponType.F_INFANTRY".into()], infantry_damage: Some(0.28), infantry_range: Some(2), ..Default::default() }; // Twenty-eight troopers at 0.28 each, landing 33 times in 36. 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, GUNNERY) > 0.0); assert_eq!(expected(&rifle, 7, 28, false, GUNNERY), 0.0); }
/// Artillery, tags, anti-missile systems and the rest are not firepower, /// whatever damage MegaMek records for them. #[test] fn what_is_not_firepower_lands_nothing() { for flag in NOT_FIREPOWER { let odd = EquipmentEntry { internal_name: (*flag).into(), name: (*flag).into(), classes: vec!["WeaponType".into()], flags: vec![format!("WeaponType.{flag}")], average_damage: Some(25.0), short_range: Some(3), medium_range: Some(6), long_range: Some(9), ..Default::default() }; assert_eq!(expected(&odd, 3, 1, false, GUNNERY), 0.0, "{flag}"); } }}