diff --git a/crates/helm-bv/src/check.rs b/crates/helm-bv/src/check.rs new file mode 100644 index 0000000..e4612c0 --- /dev/null +++ b/crates/helm-bv/src/check.rs @@ -0,0 +1,445 @@ +//! What is wrong with a `.mul` somebody uploaded. +//! +//! A file that arrives from outside is not a file this wrote, and the reader +//! is deliberately forgiving: a location index it does not recognise is +//! skipped, an unparseable armour figure is left out, more structure than a +//! design has is clamped when it is scored. That is the right behaviour for a +//! reader - a force that is nine tenths readable is worth reading - and it is +//! the wrong behaviour for an upload, where the nine tenths get stored and +//! nobody is told about the last tenth. +//! +//! So the checks here are mostly about what the reader forgave. Two of them +//! can only be answered from the document itself rather than from the model, +//! because by the time it is a model the evidence is gone. +//! +//! Everything is reported rather than the first thing found, and nothing is +//! refused: what to do about a force whose crew is impossible is a decision +//! for whatever is holding it, not for the rules. + +use helm_core::{Catalogue, Unit}; +use helm_unitfile::MulUnit; + +use crate::{Condition, structure}; + +/// Whether a complaint is about the file or about this crate. +/// +/// The distinction decides what an application does with it. A file that +/// contradicts itself is one to hand back to whoever uploaded it; a file +/// carrying a tank is a perfectly good file that helm cannot score yet, and +/// refusing it would be refusing the player for our own gap. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Severity { + /// The file is wrong: it says something no machine could be. + Wrong, + /// The file is fine and helm cannot answer about it. + Unsupported, +} + +/// One thing wrong with one machine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Trouble { + /// No design in the library answers to this chassis and model, so there is + /// nothing to check the rest against and nothing to score. + UnknownDesign, + /// A crew skill outside 0-8. MegaMek's own range, and the battle value + /// table has no row for anything else. + Skill { what: &'static str, value: u8 }, + /// More wounds than a MekWarrior can take. Six is dead. + PilotHits(u8), + /// The file describes a location this design's shape does not have - a + /// quad with an arm, a biped with a fourth leg. + /// + /// Found in the document rather than in the model: the reader skips an + /// index it cannot place, so whatever was in that location is already + /// gone by the time anything else could notice. + NoSuchLocation { index: usize }, + /// The file says the machine is a shape the design is not. + /// + /// Worth its own entry because it is not a small error: locations are + /// numbered, and the numbering differs by shape, so reading a quad's file + /// as a biped's puts its legs in its arms and scores the result without + /// complaining. + WrongShape { file: String, design: String }, + /// More plate than the design was built with. + TooMuchArmor { + location: String, + claimed: i64, + built: i64, + }, + /// More frame than the design was built with. Scoring clamps this, so a + /// file with it is scored as though it did not. + TooMuchStructure { + location: String, + claimed: i64, + built: i64, + }, + /// A critical slot the location does not have. A `.mul` numbers slots from + /// one, and this is the error an exporter that forgot makes. + NoSuchSlot { + location: String, + slot: usize, + slots: usize, + }, + /// The design was found and cannot be scored, which is a limit here rather + /// than a fault in the file. + Unscoreable(String), +} + +impl Trouble { + /// Whether this is the file's fault or ours. + pub fn severity(&self) -> Severity { + match self { + Trouble::Unscoreable(_) => Severity::Unsupported, + _ => Severity::Wrong, + } + } +} + +impl std::fmt::Display for Trouble { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Trouble::UnknownDesign => write!(f, "no design in the library answers to this name"), + Trouble::Skill { what, value } => write!(f, "{what} {value} is outside 0-8"), + Trouble::PilotHits(hits) => write!(f, "{hits} pilot hits, and six is dead"), + Trouble::NoSuchLocation { index } => { + write!(f, "location {index}, which this shape does not have") + } + Trouble::WrongShape { file, design } => { + write!( + f, + "the file calls this a {file} and the design is a {design}" + ) + } + Trouble::TooMuchArmor { + location, + claimed, + built, + } => write!(f, "{location} has {claimed} armour, built with {built}"), + Trouble::TooMuchStructure { + location, + claimed, + built, + } => write!(f, "{location} has {claimed} structure, built with {built}"), + Trouble::NoSuchSlot { + location, + slot, + slots, + } => write!(f, "{location} slot {}, and it has {slots}", slot + 1), + Trouble::Unscoreable(why) => write!(f, "cannot be scored: {why}"), + } + } +} + +/// Everything wrong with one machine, in the order the checks are written. +/// +/// `design` is what the chassis and model resolved to, or `None` if nothing +/// did - looking that up needs the library, which this crate deliberately +/// cannot reach. An ejected crew is a person rather than a machine and is not +/// worth checking; [`MulUnit::is_crew`] is the test, and the caller applies it. +pub fn check(design: Option<&Unit>, catalogue: &Catalogue, entity: &MulUnit) -> Vec { + let mut found = Vec::new(); + + for (what, value) in [("gunnery", entity.gunnery), ("piloting", entity.piloting)] { + if value > 8 { + found.push(Trouble::Skill { what, value }); + } + } + if entity.pilot_hits > 6 { + found.push(Trouble::PilotHits(entity.pilot_hits)); + } + + // The document, not the model: an index outside the shape never reached + // the model at all. + let shape = helm_unitfile::locations_for(entity.unit_type.as_deref()); + for location in entity + .source + .children + .iter() + .filter(|c| c.name == "location") + { + let Some(index) = location.get("index").and_then(|i| i.trim().parse().ok()) else { + continue; + }; + if index >= shape.len() { + found.push(Trouble::NoSuchLocation { index }); + } + } + + let Some(design) = design else { + found.push(Trouble::UnknownDesign); + return found; + }; + + if let Some(file) = entity.unit_type.as_deref() { + let ours = structure::Shape::from_config(design.config.as_deref()); + let theirs = structure::Shape::from_config(Some(file)); + // Only where the file says something this understands: `Biped` against + // a biped is agreement, and a `Tank` against a Mek is a different + // complaint that scoring already makes. + if is_mek_shape(file) && ours != theirs { + found.push(Trouble::WrongShape { + file: file.to_string(), + design: design.config.clone().unwrap_or_else(|| "Biped".into()), + }); + } + } + + for (location, claimed) in &entity.armor { + if let Some(built) = design.armor_locations.get(location) + && claimed > built + { + found.push(Trouble::TooMuchArmor { + location: location.clone(), + claimed: *claimed, + built: *built, + }); + } + } + + if let Some(tons) = design.mass { + let shape = structure::Shape::from_config(design.config.as_deref()); + if let Some(built) = structure::per_location(tons, shape) { + for (location, claimed) in &entity.structure { + let Some((_, built)) = built.iter().find(|(l, _)| l == location) else { + continue; + }; + if claimed > built { + found.push(Trouble::TooMuchStructure { + location: location.clone(), + claimed: *claimed, + built: *built, + }); + } + } + } + } + + // A slot can be both shot out and empty, and that is one bad slot rather + // than two, so the same complaint is not made twice. + for named in [&entity.destroyed, &entity.empty_ammo] { + for (location, slots) in named { + let Some(criticals) = design.criticals.get(location) else { + continue; + }; + for slot in slots { + if *slot >= criticals.len() { + let trouble = Trouble::NoSuchSlot { + location: location.clone(), + slot: *slot, + slots: criticals.len(), + }; + if !found.contains(&trouble) { + found.push(trouble); + } + } + } + } + } + + // Last, because it is the expensive one and because a file this far + // through is worth scoring. + if let Err(why) = crate::battle_value_in(design, catalogue, &Condition::from(entity)) { + found.push(Trouble::Unscoreable(why.to_string())); + } + + found +} + +/// Whether a `.mul`'s `type` is one of the shapes a Mek comes in. +/// +/// Anything else - `Tank`, `Infantry` - is a unit type rather than a shape, +/// and disagreeing with a Mek about it is not a shape complaint. +fn is_mek_shape(kind: &str) -> bool { + ["biped", "quad", "tripod", "quadvee"] + .iter() + .any(|k| kind.eq_ignore_ascii_case(k)) +} + +#[cfg(test)] +mod tests { + use super::*; + use helm_unitfile::Kept; + + fn design() -> Unit { + Unit { + chassis: "Atlas".into(), + model: "AS7-D".into(), + format_str: "mtf".into(), + config: Some("Biped".into()), + mass: Some(100.0), + armor_locations: [("CT".to_string(), 47), ("RTC".to_string(), 14)] + .into_iter() + .collect(), + criticals: [("Right Arm".to_string(), vec![String::new(); 12])] + .into_iter() + .collect(), + ..Default::default() + } + } + + fn entity() -> MulUnit { + MulUnit { + chassis: "Atlas".into(), + model: "AS7-D".into(), + gunnery: 4, + piloting: 5, + ..Default::default() + } + } + + /// The catalogue is empty in these, so scoring always fails - and that is + /// this crate's own limit rather than the file's, which is the whole point + /// of the split. + fn faults(design: Option<&Unit>, entity: &MulUnit) -> Vec { + check(design, &Catalogue::default(), entity) + .into_iter() + .filter(|t| t.severity() == Severity::Wrong) + .collect() + } + + #[test] + fn a_file_that_says_nothing_impossible_has_nothing_wrong_with_it() { + assert_eq!(faults(Some(&design()), &entity()), vec![]); + } + + #[test] + fn a_design_nothing_answers_to_stops_the_rest() { + let mut broken = entity(); + broken.armor.insert("CT".into(), 9000); + // One complaint, not two: there is nothing to check the armour + // against, so reporting it as too much would be inventing a figure. + assert_eq!(faults(None, &broken), vec![Trouble::UnknownDesign]); + } + + #[test] + fn a_crew_outside_the_table_is_reported_by_name() { + let mut wrong = entity(); + wrong.gunnery = 9; + wrong.pilot_hits = 7; + assert_eq!( + faults(Some(&design()), &wrong), + vec![ + Trouble::Skill { + what: "gunnery", + value: 9 + }, + Trouble::PilotHits(7), + ] + ); + } + + /// Rear plate is a location of its own in a `.mtf` and part of the torso + /// in a `.mul`, so this is the check most likely to compare two different + /// things and never fire. + #[test] + fn more_plate_than_the_design_was_built_with() { + let mut wrong = entity(); + wrong.armor.insert("CT".into(), 48); + wrong.armor.insert("RTC".into(), 15); + assert_eq!( + faults(Some(&design()), &wrong), + vec![ + Trouble::TooMuchArmor { + location: "CT".into(), + claimed: 48, + built: 47 + }, + Trouble::TooMuchArmor { + location: "RTC".into(), + claimed: 15, + built: 14 + }, + ] + ); + } + + /// Structure comes from the tonnage table rather than from the file, so an + /// Atlas's centre torso is 31 whatever the design says. + #[test] + fn more_frame_than_the_tonnage_allows() { + let mut wrong = entity(); + wrong.structure.insert("CT".into(), 32); + assert_eq!( + faults(Some(&design()), &wrong), + vec![Trouble::TooMuchStructure { + location: "CT".into(), + claimed: 32, + built: 31 + }] + ); + } + + /// The slot numbering is the trap: a `.mul` counts from one and everything + /// else here from zero, so an exporter that forgot writes a slot past the + /// end of every location. + #[test] + fn a_slot_the_location_does_not_have() { + let mut wrong = entity(); + wrong.destroyed.insert("Right Arm".into(), [12].into()); + wrong.empty_ammo.insert("Right Arm".into(), [12].into()); + // Shot out and empty is one bad slot, not two. + assert_eq!( + faults(Some(&design()), &wrong), + vec![Trouble::NoSuchSlot { + location: "Right Arm".into(), + slot: 12, + slots: 12 + }] + ); + } + + /// The one that silently corrupts rather than merely losing something: + /// locations are numbered and the numbering differs by shape, so a quad + /// read as a biped has its legs where its arms should be. + #[test] + fn a_shape_the_design_is_not() { + let mut wrong = entity(); + wrong.unit_type = Some("Quad".into()); + assert_eq!( + faults(Some(&design()), &wrong), + vec![Trouble::WrongShape { + file: "Quad".into(), + design: "Biped".into() + }] + ); + } + + /// A tank in a force is not a fault in the file. helm scores Meks and + /// nothing else yet, and refusing the upload would be charging a player + /// for our own gap. + #[test] + fn a_unit_type_we_cannot_score_is_not_the_file_being_wrong() { + let tank = Unit { + format_str: "blk".into(), + unit_type: Some("Tank".into()), + ..Default::default() + }; + let troubles = check(Some(&tank), &Catalogue::default(), &entity()); + assert!( + troubles + .iter() + .all(|t| t.severity() == Severity::Unsupported) + ); + assert!(matches!(troubles.as_slice(), [Trouble::Unscoreable(_)])); + } + + /// A location index outside the shape never reaches the model - the reader + /// skips it - so this is checked against the document or not at all. + #[test] + fn a_location_the_shape_does_not_have_is_found_in_the_document() { + let mut wrong = entity(); + wrong.source = Kept { + name: "entity".into(), + children: vec![Kept { + name: "location".into(), + attributes: vec![("index".into(), "8".into())], + ..Default::default() + }], + ..Default::default() + }; + assert_eq!( + faults(Some(&design()), &wrong), + vec![Trouble::NoSuchLocation { index: 8 }] + ); + } +} diff --git a/crates/helm-bv/src/lib.rs b/crates/helm-bv/src/lib.rs index 4890272..5c44f99 100644 --- a/crates/helm-bv/src/lib.rs +++ b/crates/helm-bv/src/lib.rs @@ -33,6 +33,7 @@ //! because a force list built on it looks fine. mod attribution; +mod check; mod clusters; mod conformance; mod defensive; @@ -44,6 +45,7 @@ mod rules; mod structure; pub use attribution::{Attribution, Change, Term, Terms, attribute, between, terms_in}; +pub use check::{Severity, Trouble, check}; pub use clusters::{Cluster, clusters, labels, oddity}; pub use conformance::{Conformance, Mismatch, Tally}; pub use defensive::{Defensive, defensive, tmm}; diff --git a/crates/helm-unitfile/src/lib.rs b/crates/helm-unitfile/src/lib.rs index 123809d..86312ec 100644 --- a/crates/helm-unitfile/src/lib.rs +++ b/crates/helm-unitfile/src/lib.rs @@ -27,7 +27,7 @@ mod mul; pub use blk::parse_blk; pub use mtf::{armor_location_order, parse_mtf, split_pipe_list, split_system_field}; -pub use mul::{Kept, MUL_VERSION, Mul, MulUnit, parse_mul, write_mul}; +pub use mul::{Kept, MUL_VERSION, Mul, MulUnit, locations_for, parse_mul, write_mul}; #[cfg(feature = "library")] mod library; diff --git a/crates/helm-unitfile/src/mul.rs b/crates/helm-unitfile/src/mul.rs index d40f668..6468ce0 100644 --- a/crates/helm-unitfile/src/mul.rs +++ b/crates/helm-unitfile/src/mul.rs @@ -238,7 +238,12 @@ const QUAD_LOCATIONS: [(&str, &str); 8] = [ /// The `type` on an entity is its movement mode, which for a Mek is its shape. /// A file that does not say is read as a biped, which all but a couple of /// hundred designs are. -fn locations_for(kind: Option<&str>) -> &'static [(&'static str, &'static str)] { +/// +/// Public because reading a `.mul` is not the only thing that needs it: a +/// location index this does not cover is silently skipped on the way in, so +/// anything checking a file for what it lost has to ask the same question the +/// reader asked. +pub fn locations_for(kind: Option<&str>) -> &'static [(&'static str, &'static str)] { match kind { Some(k) if k.eq_ignore_ascii_case("quad") || k.eq_ignore_ascii_case("quadvee") => { &QUAD_LOCATIONS