//! Whether a force is a Fire Lance or just four Meks. //! //! Campaign Operations defines formations: a Battle Lance, a Recon Lance, a //! Fire Support Star. Each is a blueprint - an ideal role, weight bounds, and //! a list of requirements like "at least three units with the Sniper or //! Missile Boat role" - and a force either meets it or does not. Meeting one //! is what earns a force its Force Bonus, and it is also the answer to the //! question a player actually asks while building: *what is this lance for?* //! //! MegaMek holds the blueprints in `FormationType`, and helm reads them off //! the shelf through `bridge/DumpFormations.java` rather than copying them. //! What it cannot read is the requirements themselves: each is a Java lambda //! with a human-readable label beside it, and only the label crosses the //! bridge. So [`Rule::parse`] reads the label back into a predicate, and //! MegaMek's own per-unit answers are the oracle that says whether the reading //! was right. //! //! That arrangement decides the failure mode, which is the important part. A //! label this module cannot parse becomes [`Rule::Unreadable`], a force //! carrying one is reported as [`Verdict::Unknown`], and nothing ever guesses. //! An upstream release that rewords a label costs an answer, not a wrong one. use std::collections::BTreeMap; /// A unit as a formation rule sees it. /// /// Every field here is one MegaMek's `MekSummary` carries and helm already /// computes, which is what keeps this crate free of the catalogue: the caller /// looks the facts up once and the rules read them. #[derive(Debug, Clone, Default, PartialEq)] pub struct Member { pub chassis: String, pub model: String, /// MegaMek's `UnitRole` name, capitalised - `Missile Boat`, `Juggernaut`. pub role: Option, /// `Mek`, `Tank`, `BattleArmor` - `UnitType.getTypeName`. pub unit_type: Option, /// 0 ultra light through 5 super heavy, `EntityWeightClass`. pub weight_class: Option, pub walk_mp: Option, pub jump_mp: Option, /// Armour points, totalled over the locations. pub armor: Option, /// Total damage from everything that reaches, by range band. /// `helm_core::damage_at_range`. pub damage_at: BTreeMap, /// The hardest single hit that reaches, by range band. /// `helm_core::max_single_damage_at_range`. pub single_damage_at: BTreeMap, /// The equipment questions the rules ask, answered once by the caller. /// /// `None` means nobody looked, which is not the same as carrying none of /// it. A blueprint asking about equipment reports [`Verdict::Unknown`] /// against a unit that was never asked, rather than failing it - which is /// what a bare empty list would silently do to every Anti-Air Lance. pub carries: Option>, } /// The equipment a formation rule asks about. /// /// A closed list rather than an equipment expression, because the rules ask /// about exactly these and nothing else. Working them out needs the catalogue /// and this crate does not have one, so the caller supplies the answers - /// `helm_core::formation_equipment` is the function that finds them. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Carried { /// A weapon that can fire indirectly - MegaMek's `hasIndirectFire`. IndirectFire, /// An artillery weapon. Artillery, /// Any autocannon, LB-X and Ultra included. Autocannon, /// An LB-X autocannon specifically. LbxAutocannon, /// An autocannon that is neither LB-X nor Ultra nor rotary. StandardAutocannon, /// A short-range missile launcher. Srm, /// A long-range missile launcher. Lrm, /// An active probe. ActiveProbe, /// An ECM suite. Ecm, /// A TAG designator. Tag, /// The design quirk that makes a unit good at shooting down aircraft. AntiAirQuirk, } /// One thing a blueprint asks of a single unit. #[derive(Debug, Clone, PartialEq)] pub enum Rule { /// Matches everything. What a blueprint with no main requirement has. Anything, /// Any of these `UnitRole` names. Role(Vec), /// Any of these `UnitType` names. UnitType(Vec), /// A weight class band, both ends inclusive. WeightClass { min: i64, max: i64 }, /// Walking or cruising MP, both ends inclusive and either open. Walk { min: Option, max: Option }, /// Jumping MP, at least. Jump(i64), /// Armour points, at least. Armor(i64), /// Total damage at a range band. Damage { range: i64, min: Option, max: Option, }, /// The hardest single hit at a range band, at least. SingleDamage { range: i64, min: i64 }, /// Carries at least one of these. Carries(Vec), /// Every part holds. All(Vec), /// At least one part holds. Any(Vec), /// A label this module could not read. Never matches, and taints the /// verdict of any force it is asked about rather than failing it. Unreadable(String), } impl Rule { /// Whether one unit satisfies this rule. /// /// A fact the caller did not supply fails the rule rather than passing it. /// A blueprint asking for armour is asking about a unit whose armour is /// known, and treating an absent figure as satisfactory is how a force /// full of unread units qualifies as anything. pub fn matches(&self, unit: &Member) -> bool { match self { Rule::Anything => true, Rule::Role(roles) => unit .role .as_deref() .is_some_and(|r| roles.iter().any(|want| want.eq_ignore_ascii_case(r))), Rule::UnitType(types) => unit .unit_type .as_deref() .is_some_and(|t| types.iter().any(|want| want.eq_ignore_ascii_case(t))), Rule::WeightClass { min, max } => { unit.weight_class.is_some_and(|wc| wc >= *min && wc <= *max) } Rule::Walk { min, max } => unit .walk_mp .is_some_and(|mp| min.is_none_or(|m| mp >= m) && max.is_none_or(|m| mp <= m)), Rule::Jump(min) => unit.jump_mp.is_some_and(|mp| mp >= *min), Rule::Armor(min) => unit.armor.is_some_and(|a| a >= *min), Rule::Damage { range, min, max } => unit .damage_at .get(range) .is_some_and(|d| min.is_none_or(|m| *d >= m) && max.is_none_or(|m| *d <= m)), Rule::SingleDamage { range, min } => { unit.single_damage_at.get(range).is_some_and(|d| *d >= *min) } Rule::Carries(wanted) => unit .carries .as_ref() .is_some_and(|has| wanted.iter().any(|w| has.contains(w))), Rule::All(parts) => parts.iter().all(|p| p.matches(unit)), Rule::Any(parts) => parts.iter().any(|p| p.matches(unit)), Rule::Unreadable(_) => false, } } /// Whether this rule asks what a unit carries, at any depth. /// /// Asked before a verdict rather than during one: a rule about equipment /// is unanswerable for a unit whose equipment was never looked up, and the /// difference between unanswerable and false is the whole point. pub fn needs_equipment(&self) -> bool { match self { Rule::Carries(_) => true, Rule::All(parts) | Rule::Any(parts) => parts.iter().any(Rule::needs_equipment), _ => false, } } /// Whether this rule, or any part of it, could not be read. pub fn readable(&self) -> bool { match self { Rule::Unreadable(_) => false, Rule::All(parts) | Rule::Any(parts) => parts.iter().all(Rule::readable), _ => true, } } } /// Labels that do not describe the requirement they are attached to. /// /// Two of the forty-four do, and both were found by measuring helm's reading /// against MegaMek's own answers rather than by reading the labels harder. /// Both are recorded in `UPSTREAM.md`; the readings here are what the /// predicates actually test, and the oracle test holds them to it. /// /// Keyed on the label alone because each of these strings appears once in the /// whole set, which `every_corrected_label_is_used_once` checks. fn corrected(label: &str) -> Option { Some(match label { // The Rifle Lance. Labelled as two weight classes; tests // `getWeightClass() <= WEIGHT_HEAVY`, which takes light and ultra // light as well. "Medium, Heavy" => Rule::WeightClass { min: 0, max: 3 }, // The Fast Assault Lance. Labelled as movement alone; tests // `getTotalArmor() >= 135 && (getWalkMp() >= 5 || getJumpMp() > 0)`, // and a light Mek that can run and jump meets the label while missing // the requirement by a hundred points of plate. "Walk 5+ or Jump 1+" => Rule::All(vec![ Rule::Armor(135), Rule::Any(vec![ Rule::Walk { min: Some(5), max: None, }, Rule::Jump(1), ]), ]), _ => return None, }) } /// The `UnitRole` a label names, in the spelling a unit record carries. /// /// MegaMek's own `UnitRole.parseRole` accepts several spellings per role - /// `missile_boat`, `missile boat`, `attack` for `ATTACK_FIGHTER` - and the /// labels use them freely, so this accepts the same set. `Juggernaught` is /// not one of them: it is a misspelling in the Command Lance label, and it is /// taken here because refusing it would lose a formation over a typo. fn role_name(token: &str) -> Option<&'static str> { let token = token.trim().to_ascii_lowercase(); Some(match token.replace('_', " ").as_str() { "ambusher" => "Ambusher", "brawler" => "Brawler", "juggernaut" | "juggernaught" => "Juggernaut", "missile boat" => "Missile Boat", "scout" => "Scout", "skirmisher" => "Skirmisher", "sniper" => "Sniper", "striker" => "Striker", "attack" | "attack fighter" => "Attack Fighter", "dogfighter" => "Dogfighter", "fast dogfighter" => "Fast Dogfighter", "fire support" | "fire-support" => "Fire Support", "interceptor" => "Interceptor", "transport" => "Transport", _ => return None, }) } /// `EntityWeightClass`: 0 ultra light through 5 super heavy. const SUPER_HEAVY: i64 = 5; /// The weight band a label names. `Heavy+` is heavy and up, `Not assault` is /// everything below assault, a bare class is that class alone. fn weight_band(token: &str) -> Option { let token = token.trim(); let (name, open_ended) = match token.strip_suffix('+') { Some(rest) => (rest.trim(), true), None => (token, false), }; let (name, negated) = match name.strip_prefix("Not ") { Some(rest) => (rest.trim(), true), None => (name, false), }; let class = match name.to_ascii_lowercase().as_str() { "ultra light" => 0, "light" => 1, "medium" => 2, "heavy" => 3, "assault" => 4, "super heavy" => SUPER_HEAVY, _ => return None, }; Some(match (negated, open_ended) { (true, _) => Rule::WeightClass { min: 0, max: class - 1, }, (false, true) => Rule::WeightClass { min: class, max: SUPER_HEAVY, }, (false, false) => Rule::WeightClass { min: class, max: class, }, }) } /// The equipment a label names. fn carried(token: &str) -> Option { Some(match token.trim().to_ascii_lowercase().as_str() { "indirect fire weapon" => Carried::IndirectFire, "artillery" | "artillery weapon" => Carried::Artillery, "ac" | "ac weapon" => Carried::Autocannon, "standard ac" => Carried::StandardAutocannon, "lbx" | "lb-x" => Carried::LbxAutocannon, "srm" => Carried::Srm, "lrm" => Carried::Lrm, "probe" => Carried::ActiveProbe, "ecm" => Carried::Ecm, "tag" => Carried::Tag, "anti-air targeting quirk" => Carried::AntiAirQuirk, _ => return None, }) } /// Parse `12`, or `12+` as a minimum, or `<= 12` as a maximum. fn bound(token: &str) -> Option<(Option, Option)> { let token = token.trim(); if let Some(rest) = token.strip_prefix("<=") { return Some((None, Some(rest.trim().parse().ok()?))); } if let Some(rest) = token.strip_suffix('+') { return Some((Some(rest.trim().parse().ok()?), None)); } // A bare number is a minimum too. Both spellings appear for the same // requirement - the Assault Lance label says "25 damage at range 7" and // the Fast Assault Lance says "Damage 25+ at range 7" - and they are the // same rule, so reading the bare one as an equality would fail every // design that carries more guns than the minimum. Some((Some(token.parse().ok()?), None)) } /// Parse one indivisible requirement. fn atom(label: &str) -> Rule { let label = label.trim(); if label.is_empty() { return Rule::Anything; } // A slash separates alternatives rather than words: `Infantry/BA` and // `Interceptor/Fast Dogfighter` are both lists of two. if label.contains('/') && !label.starts_with("Walk/Cruise") { let parts: Vec<&str> = label.split('/').map(str::trim).collect(); if parts.iter().all(|p| role_name(p).is_some()) { return Rule::Role( parts .iter() .filter_map(|p| role_name(p)) .map(String::from) .collect(), ); } if label.eq_ignore_ascii_case("infantry/ba") { return Rule::UnitType(vec!["Infantry".into(), "BattleArmor".into()]); } } if let Some(role) = role_name(label) { return Rule::Role(vec![role.into()]); } if let Some(rule) = weight_band(label) { return rule; } if let Some(item) = carried(label) { return Rule::Carries(vec![item]); } let lower = label.to_ascii_lowercase(); if let Some(rest) = lower .strip_prefix("walk/cruise") .or_else(|| lower.strip_prefix("walk")) && let Some((min, max)) = bound(rest) { return Rule::Walk { min, max }; } if let Some(rest) = lower.strip_prefix("jump") && let Some((Some(min), None)) = bound(rest) { return Rule::Jump(min); } if let Some(rest) = lower.strip_prefix("armor") && let Some((Some(min), None)) = bound(rest) { return Rule::Armor(min); } if let Some(rule) = damage(&lower) { return rule; } Rule::Unreadable(label.to_string()) } /// `Damage 10+ at range 9`, `Damage <= 10 at range 9`, `25 damage at range 7`, /// and the single-weapon form `Weapon with damage 5+ at range 15`. fn damage(lower: &str) -> Option { let (head, tail) = lower.split_once(" at range ")?; let range: i64 = tail.trim().parse().ok()?; let single = head.starts_with("weapon with damage"); let amount = head .strip_prefix("weapon with damage") .or_else(|| head.strip_prefix("damage")) .map_or_else( || head.trim_end_matches(" damage").to_string(), str::to_string, ); let (min, max) = bound(&amount)?; Some(if single { Rule::SingleDamage { range, min: min? } } else { Rule::Damage { range, min, max } }) } impl Rule { /// Read a blueprint's label back into a predicate. /// /// Commas mean two different things and the parts decide which. A list of /// roles is a choice - `Sniper, Missile Boat` wants either - while a list /// that is not all of one kind is a conjunction: `Walk/Cruise 6+, Scout` /// wants both. That is not a rule this module invents; it is what the /// labels do, and reading them any other way makes a Recon Lance out of /// anything that can run. pub fn parse(label: &str) -> Rule { let label = label.trim(); if label.is_empty() { return Rule::Anything; } if let Some(rule) = corrected(label) { return rule; } // `AC, SRM, or LRM` is one list written with both separators. let atoms: Vec<&str> = label .split(" or ") .flat_map(|p| p.split(',')) .map(str::trim) .filter(|p| !p.is_empty()) .collect(); if atoms.len() > 1 { if atoms.iter().all(|a| role_name(a).is_some()) { return Rule::Role( atoms .iter() .filter_map(|a| role_name(a)) .map(String::from) .collect(), ); } if atoms.iter().all(|a| carried(a).is_some()) { return Rule::Carries(atoms.iter().filter_map(|a| carried(a)).collect()); } if atoms.iter().all(|a| weight_band(a).is_some()) { return Rule::Any(atoms.iter().filter_map(|a| weight_band(a)).collect()); } } let alternatives: Vec<&str> = label.split(" or ").map(str::trim).collect(); if alternatives.len() > 1 { return Rule::Any(alternatives.into_iter().map(Rule::parse).collect()); } let parts: Vec<&str> = label .split(',') .map(str::trim) .filter(|p| !p.is_empty()) .collect(); if parts.len() > 1 { return Rule::All(parts.into_iter().map(atom).collect()); } atom(label) } } /// One requirement, and how many units have to meet it. /// /// The minimum is a curve rather than a number because upstream has two kinds /// of requirement - a fixed count and a proportion of the force - and which /// one a requirement is happens to be a private field. The bridge dumps what /// each asks of a force of 1 through 20 instead, which says the same thing /// without reading it. #[derive(Debug, Clone, PartialEq)] pub struct Requirement { /// The label upstream shows, kept verbatim so a report can quote it. pub description: String, pub rule: Rule, /// What is asked of a force of 1, 2, ... 20 units. pub minimums: Vec, /// This requirement is an alternative to the one after it: failing it is /// forgiven if the next one is met. pub paired_with_next: bool, /// This requirement is the second half of such a pair, and is checked /// through the first rather than on its own. pub paired_with_previous: bool, } impl Requirement { /// How many units of a force this size must meet it. /// /// Beyond the dumped curve the proportion is recovered from it: a fixed /// count is flat and stays flat, and a proportion is the one fraction /// whose ceiling reproduces all twenty samples. Nothing here guesses - /// [`Requirement::proportion`] returns `None` when the curve is neither, /// and the minimum with it. pub fn minimum(&self, force_size: usize) -> Option { if force_size == 0 { return Some(0); } if let Some(m) = self.minimums.get(force_size - 1) { return Some(*m); } if let Some(pct) = self.proportion() { return Some((pct * force_size as f64).ceil() as i64); } // A flat curve is a fixed count, and a fixed count does not care how // large the force is. let first = *self.minimums.first()?; self.minimums.iter().all(|m| *m == first).then_some(first) } /// The fraction of a force this requirement asks for, where it is one. /// /// Each sample `m` at size `n` says the fraction lies in `((m-1)/n, m/n]`. /// Twenty of those intersect to a narrow interval, and its midpoint /// reproduces every sample. A flat curve is a fixed count and has no /// fraction, so it returns `None` and so does anything inconsistent. pub fn proportion(&self) -> Option { if self.minimums.len() < 2 || self.minimums.iter().all(|m| *m == self.minimums[0]) { return None; } let (mut low, mut high) = (0.0_f64, f64::INFINITY); for (i, m) in self.minimums.iter().enumerate() { let n = (i + 1) as f64; low = low.max((*m as f64 - 1.0) / n); high = high.min(*m as f64 / n); } if low >= high { return None; } let pct = f64::midpoint(low, high); let reproduces = self .minimums .iter() .enumerate() .all(|(i, m)| (pct * (i + 1) as f64).ceil() as i64 == *m); reproduces.then_some(pct) } } /// A rule about pairs of units rather than about one unit. /// /// The Ranger Lance wants two units of the same chassis; the Order Lance wants /// every unit to be the same chassis. Upstream keeps this apart from the other /// requirements for that reason, and so does this. #[derive(Debug, Clone, PartialEq)] pub struct Grouping { pub description: String, /// Which units it applies to; empty means all of them. pub unit_types: Vec, /// How many units make a group. /// /// Zero means the rule cannot be answered. The Order Lance is written that /// way upstream and MegaMek throws rather than answering it - see /// `UPSTREAM.md` - so a blueprint carrying one gets no verdict here. pub group_size: i64, /// How many such groups the force needs. pub num_groups: i64, } /// A formation as Campaign Operations defines it. #[derive(Debug, Clone, PartialEq)] pub struct Blueprint { pub name: String, /// The heading it is filed under - `Fire`, `Recon`, `Assault`. pub category: String, /// The role every unit would have in the ideal version of this formation. /// A force where every unit has it qualifies whatever else is true. pub ideal_role: Option, pub min_weight_class: i64, pub max_weight_class: i64, /// What every unit must satisfy, where there is such a rule. pub main: Rule, pub main_description: String, /// Which unit types may appear at all. pub unit_types: Vec, pub criteria: Vec, /// The rule about pairs, where the blueprint has one. /// /// Read off the shelf and offered to callers, but **not applied** by /// [`Blueprint::evaluate`], because there is nothing to check an /// application against. MegaMek filters the force through its RAT /// generator's model records before this rule looks at it, and a record it /// has not loaded drops the unit - so with the generator's data absent the /// force it groups is empty and the rule passes whatever is in front of /// it. Measured rather than assumed: a Heavy Battle Lance of four /// different chassis qualifies upstream, and a Heavy Battle Lance wants /// two pairs of the same model. /// /// Enforcing it here would make helm stricter than the thing it conforms /// to, in a way no oracle could confirm or deny. pub grouping: Option, } /// What a blueprint says about a force. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Verdict { /// The force is this formation. Qualifies, /// It is not, and here is the first requirement it failed. Fails(String), /// A requirement could not be read, so there is no answer. The label is /// carried so a caller can say which one. Unknown(String), } impl Verdict { /// Whether the force meets every requirement the blueprint sets. pub fn qualifies(&self) -> bool { matches!(self, Verdict::Qualifies) } } impl Blueprint { /// Whether a force is this formation. /// /// The order is upstream's, and two parts of it are load-bearing. A force /// whose every unit already has the ideal role qualifies before any /// requirement is looked at - a lance of four Juggernauts is an Assault /// Lance whatever its guns say. And a requirement marked as paired with /// the next is an alternative to it rather than an addition, so failing /// the first is forgiven when the second is met. pub fn evaluate(&self, force: &[Member]) -> Verdict { if force.is_empty() { return Verdict::Fails("no units".into()); } if !self.unit_types.is_empty() && let Some(bad) = force.iter().find(|u| { u.unit_type .as_deref() .is_none_or(|t| !self.unit_types.iter().any(|a| a == t)) }) { return Verdict::Fails(format!( "{} {} is not a unit type this formation takes", bad.chassis, bad.model )); } if let Some(ideal) = &self.ideal_role && force .iter() .all(|u| u.role.as_deref().is_some_and(|r| r == ideal)) { return Verdict::Qualifies; } if !self.main.readable() || (self.main.needs_equipment() && force.iter().any(|u| u.carries.is_none())) { return Verdict::Unknown(self.main_description.clone()); } for unit in force { if !self.main.matches(unit) { return Verdict::Fails(format!( "{} {} does not meet {}", unit.chassis, unit.model, self.main_description )); } let Some(wc) = unit.weight_class else { return Verdict::Unknown(format!( "{} {} has no weight class", unit.chassis, unit.model )); }; if wc < self.min_weight_class || wc > self.max_weight_class { return Verdict::Fails(format!( "{} {} is outside the weight range", unit.chassis, unit.model )); } } for (i, req) in self.criteria.iter().enumerate() { if req.paired_with_previous { continue; } match self.meets(req, force) { Some(true) => continue, None => return Verdict::Unknown(req.description.clone()), Some(false) => {} } // The alternative, where this requirement has one. if req.paired_with_next && let Some(next) = self.criteria.get(i + 1) { match self.meets(next, force) { Some(true) => continue, None => return Verdict::Unknown(next.description.clone()), Some(false) => {} } } return Verdict::Fails(req.description.clone()); } // The grouping requirement is deliberately not applied here. See // [`Blueprint::grouping`] for why, and ask [`Grouping::satisfied_by`] // directly to have it decide something. Verdict::Qualifies } /// Whether enough of the force meets one requirement. `None` when the /// requirement could not be read. fn meets(&self, req: &Requirement, force: &[Member]) -> Option { if !req.rule.readable() { return None; } if req.rule.needs_equipment() && force.iter().any(|u| u.carries.is_none()) { return None; } let minimum = req.minimum(force.len())?; let met = force.iter().filter(|u| req.rule.matches(u)).count() as i64; Some(met >= minimum) } } impl Grouping { /// Whether the force holds enough same-chassis groups. /// /// Both figures are capped at the size of the force, which is upstream's /// doing: a rule asking for two groups of two is asking for nothing at all /// of a force of one, rather than failing it. pub fn satisfied_by(&self, force: &[Member]) -> Option { if self.group_size < 1 { return None; } let applies: Vec<&Member> = force .iter() .filter(|u| { self.unit_types.is_empty() || u.unit_type .as_deref() .is_some_and(|t| self.unit_types.iter().any(|a| a == t)) }) .collect(); if applies.is_empty() { return Some(true); } let size = self.group_size.min(applies.len() as i64); let wanted = self.num_groups.min(applies.len() as i64); let mut by_chassis: BTreeMap<&str, i64> = BTreeMap::new(); for unit in &applies { *by_chassis.entry(unit.chassis.as_str()).or_default() += 1; } Some(by_chassis.values().filter(|n| **n >= size).count() as i64 >= wanted) } } #[cfg(test)] mod tests { use super::*; fn mek(role: &str, weight_class: i64) -> Member { Member { chassis: "Test".into(), model: role.into(), role: Some(role.into()), unit_type: Some("Mek".into()), weight_class: Some(weight_class), walk_mp: Some(4), jump_mp: Some(0), armor: Some(200), ..Member::default() } } fn requirement(description: &str, minimum: i64) -> Requirement { Requirement { rule: Rule::parse(description), description: description.into(), minimums: vec![minimum; 20], paired_with_next: false, paired_with_previous: false, } } fn blueprint(criteria: Vec) -> Blueprint { Blueprint { name: "Test Lance".into(), category: "Test".into(), ideal_role: None, min_weight_class: 0, max_weight_class: 5, main: Rule::Anything, main_description: String::new(), unit_types: vec!["Mek".into()], criteria, grouping: None, } } #[test] fn a_list_of_roles_is_a_choice_and_a_mixed_list_is_not() { assert_eq!( Rule::parse("Sniper, Missile Boat"), Rule::Role(vec!["Sniper".into(), "Missile Boat".into()]) ); // Not a choice: both halves have to hold. assert_eq!( Rule::parse("Walk/Cruise 6+, Scout"), Rule::All(vec![ Rule::Walk { min: Some(6), max: None }, Rule::Role(vec!["Scout".into()]), ]) ); } #[test] fn or_is_a_choice_however_it_is_punctuated() { assert_eq!( Rule::parse("Juggernaut or Ambusher"), Rule::Role(vec!["Juggernaut".into(), "Ambusher".into()]) ); assert_eq!( Rule::parse("AC, SRM, or LRM"), Rule::Carries(vec![Carried::Autocannon, Carried::Srm, Carried::Lrm]) ); assert_eq!( Rule::parse("Walk/Cruise 5+ or Jump 4+"), Rule::Any(vec![ Rule::Walk { min: Some(5), max: None }, Rule::Jump(4), ]) ); } #[test] fn a_weight_band_reads_its_own_shape() { assert_eq!(Rule::parse("Heavy+"), Rule::WeightClass { min: 3, max: 5 }); assert_eq!(Rule::parse("Medium"), Rule::WeightClass { min: 2, max: 2 }); assert_eq!( Rule::parse("Not assault"), Rule::WeightClass { min: 0, max: 3 } ); } #[test] fn damage_reads_both_spellings_as_a_minimum() { let at_seven = Rule::Damage { range: 7, min: Some(25), max: None, }; assert_eq!(Rule::parse("Damage 25+ at range 7"), at_seven); assert_eq!(Rule::parse("25 damage at range 7"), at_seven); assert_eq!( Rule::parse("Damage <= 10 at range 9"), Rule::Damage { range: 9, min: None, max: Some(10) } ); assert_eq!( Rule::parse("Weapon with damage 5+ at range 18"), Rule::SingleDamage { range: 18, min: 5 } ); } #[test] fn a_label_that_cannot_be_read_never_matches_and_says_so() { let rule = Rule::parse("Something nobody has written a reading for"); assert!(!rule.readable()); assert!(!rule.matches(&mek("Sniper", 3))); } #[test] fn a_fact_nobody_supplied_fails_rather_than_passes() { let unknown = Member::default(); assert!(!Rule::parse("Heavy+").matches(&unknown)); assert!(!Rule::parse("Walk/Cruise 4+").matches(&unknown)); assert!(!Rule::parse("Armor 105+").matches(&unknown)); } #[test] fn a_rule_about_equipment_is_unanswerable_until_somebody_looks() { let mut force = vec![mek("Sniper", 3), mek("Sniper", 3)]; let plan = blueprint(vec![requirement("Indirect fire weapon", 1)]); assert_eq!( plan.evaluate(&force), Verdict::Unknown("Indirect fire weapon".into()) ); for unit in &mut force { unit.carries = Some(vec![Carried::IndirectFire]); } assert_eq!(plan.evaluate(&force), Verdict::Qualifies); } #[test] fn every_unit_having_the_ideal_role_settles_it_before_anything_else() { let mut plan = blueprint(vec![requirement("Armor 100000+", 4)]); plan.ideal_role = Some("Juggernaut".into()); let force = vec![mek("Juggernaut", 4), mek("Juggernaut", 4)]; assert_eq!(plan.evaluate(&force), Verdict::Qualifies); // One unit short of unanimous and the requirement decides after all. let mixed = vec![mek("Juggernaut", 4), mek("Sniper", 4)]; assert_eq!( plan.evaluate(&mixed), Verdict::Fails("Armor 100000+".into()) ); } #[test] fn a_paired_requirement_is_an_alternative_rather_than_an_addition() { let mut plan = blueprint(vec![requirement("Juggernaut", 2), requirement("Sniper", 2)]); plan.criteria[0].paired_with_next = true; plan.criteria[1].paired_with_previous = true; let snipers = vec![mek("Sniper", 3), mek("Sniper", 3)]; assert_eq!(plan.evaluate(&snipers), Verdict::Qualifies); let neither = vec![mek("Scout", 3), mek("Scout", 3)]; assert_eq!(plan.evaluate(&neither), Verdict::Fails("Juggernaut".into())); } #[test] fn a_unit_outside_the_weight_range_fails_the_whole_force() { let mut plan = blueprint(vec![]); plan.min_weight_class = 3; let force = vec![mek("Sniper", 3), mek("Sniper", 1)]; assert!(matches!(plan.evaluate(&force), Verdict::Fails(_))); } #[test] fn a_proportional_requirement_carries_past_the_dumped_curve() { // Three quarters: 3 of 4, 9 of 12, and 15 of 20. let req = Requirement { description: "three quarters".into(), rule: Rule::Anything, minimums: (1..=20).map(|n: i64| (n * 3 + 3) / 4).collect(), paired_with_next: false, paired_with_previous: false, }; assert_eq!(req.minimum(4), Some(3)); assert_eq!(req.minimum(12), Some(9)); // Past the curve, from the fraction it recovers. assert_eq!(req.minimum(24), Some(18)); assert!((req.proportion().unwrap() - 0.75).abs() < 0.01); } #[test] fn a_fixed_count_has_no_proportion_and_stays_flat() { let req = requirement("Sniper", 3); assert_eq!(req.proportion(), None); assert_eq!(req.minimum(4), Some(3)); assert_eq!(req.minimum(40), Some(3)); } #[test] fn a_group_of_none_is_a_rule_nobody_can_answer() { let grouping = Grouping { description: "Same model".into(), unit_types: Vec::new(), group_size: 0, num_groups: 1, }; assert_eq!(grouping.satisfied_by(&[mek("Sniper", 3)]), None); } #[test] fn two_of_a_chassis_makes_a_group() { let grouping = Grouping { description: "Same chassis".into(), unit_types: Vec::new(), group_size: 2, num_groups: 1, }; let pair = vec![mek("Sniper", 3), mek("Sniper", 3)]; assert_eq!(grouping.satisfied_by(&pair), Some(true)); let mut apart = pair.clone(); apart[1].chassis = "Other".into(); assert_eq!(grouping.satisfied_by(&apart), Some(false)); } }