//! The units themselves, as they travel. //! //! [`helm_facet::UnitFacets`] is what a predicate reads, and it is built in //! three places: from a parsed design, from a database row, and - once a //! browser is doing the filtering - from the documents helm publishes. //! //! # Why it is more than one document //! //! A screen that draws a match report wants a picture and a name. A force //! builder wants every column there is. A tournament wants the year, the tech //! base and the tonnage. Published as one file, the first of those pays 382KB //! to answer a question worth 60. //! //! So the index is a spine and a set of chunks. The spine is what identifies a //! design - the columns nothing can be said without - and each chunk is one //! group of columns, in the spine's order, as an array with no keys in it. //! //! # Why the chunks carry no names //! //! Because names are the expensive column. `chassis` and `model` are long //! strings that gzip cannot do much with: 44KB of the spine's 98KB is names //! alone. A chunk that repeated them would cost more in keys than it carries //! in values - art is 23KB as an array and 64KB as a map - and every chunk //! after the first would pay that toll again. //! //! The price is that a chunk is meaningless without the spine, and that a //! chunk from another run of helm lines up row for row and means the wrong //! designs. [`Build`] is what makes that a refusal rather than a wrong answer, //! and one command writes the whole set so there is never half of one. use crate::Build; use helm_facet::UnitFacets; use serde::{Deserialize, Serialize, de::DeserializeOwned}; /// One group of columns, published as its own file. /// /// The set is closed and the compiler enforces it: every `match` over a chunk /// is exhaustive, so a group added here fails to build until the writer writes /// it and the reader reads it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Chunk { /// Which picture each design is drawn with. Art, /// What it weighs, what it is worth, and what it costs. Figures, /// Where it comes from: the year, the tech base, the books. Paperwork, /// How it fights: movement, firepower, reach, heat, plate. Combat, /// What it does at each hex, which is the only honest way to say where a /// design's damage is: short, medium and long are per weapon. Curve, /// MegaMek's per-design quirks. Quirks, /// What it carries, as MegaMek lists it. Loadout, } impl Chunk { /// Every chunk, in the order one command writes them. pub const ALL: [Chunk; 7] = [ Chunk::Art, Chunk::Figures, Chunk::Paperwork, Chunk::Combat, Chunk::Curve, Chunk::Quirks, Chunk::Loadout, ]; /// What it is called, in a filter's `needs` and in the spine's manifest. pub fn name(self) -> &'static str { match self { Chunk::Art => "art", Chunk::Figures => "figures", Chunk::Paperwork => "paperwork", Chunk::Combat => "combat", Chunk::Curve => "curve", Chunk::Quirks => "quirks", Chunk::Loadout => "loadout", } } /// The file it is published as, beside the spine. pub fn file(self) -> String { format!("{}.json", self.name()) } /// The chunk of that name, for a caller reading one back. pub fn named(name: &str) -> Option { Chunk::ALL.into_iter().find(|c| c.name() == name) } } /// One group of columns for every design, in the spine's order. /// /// `chunk` is written so that a file cannot be mistaken for another chunk of /// the same shape - art and quirks are both one value per design, and reading /// one as the other would be silent. #[derive(Serialize, Deserialize)] pub struct Part { pub build: Build, pub chunk: Chunk, pub rows: Vec, } /// One group of columns, projected out of a unit and put back onto one. /// /// Every facet belongs to exactly one chunk. Nothing here enforces that on its /// own; `every_column_survives_the_split` does, and it cannot be written /// without naming all of them. pub trait Columns: Serialize + DeserializeOwned + Sized { /// Which chunk these columns are published in. const CHUNK: Chunk; /// Read them off a unit. fn take(unit: &UnitFacets, extra: &Extras) -> Self; /// Put them back onto one. fn put(self, unit: &mut UnitFacets, extra: &mut Extras); } /// The two columns a screen draws that no predicate reads. /// /// They travel with the facets rather than beside them because they are per /// design and positional like everything else, and a third parallel structure /// for two fields would be worse than a struct with two fields in it. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Extras { /// Where the design's picture is, relative to the release's art tree. pub sprite: Option, /// Plate as the file declares it. `armor_pct` is what a filter asks /// about; this is what a row prints. pub total_armor: Option, } /// The columns nothing can be said without: what this design is. #[derive(Debug, Default, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Spine { #[serde(skip_serializing_if = "String::is_empty")] pub name: String, #[serde(skip_serializing_if = "String::is_empty")] pub chassis: String, #[serde(skip_serializing_if = "String::is_empty")] pub model: String, #[serde(skip_serializing_if = "Option::is_none")] pub unit_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub config: Option, #[serde(skip_serializing_if = "Option::is_none")] pub mul_id: Option, } /// Which picture each design is drawn with. #[derive(Debug, Default, Serialize, Deserialize)] #[serde(transparent)] pub struct Art(pub Option); impl Columns for Art { const CHUNK: Chunk = Chunk::Art; fn take(_unit: &UnitFacets, extra: &Extras) -> Self { Art(extra.sprite.clone()) } fn put(self, _unit: &mut UnitFacets, extra: &mut Extras) { extra.sprite = self.0; } } /// What it weighs, what it is worth, and what it costs. #[derive(Debug, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Figures { #[serde(rename = "mass", skip_serializing_if = "Option::is_none")] pub tons: Option, #[serde(rename = "bv", skip_serializing_if = "Option::is_none")] pub battle_value: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cost: Option, #[serde(skip_serializing_if = "Option::is_none")] pub weight_class: Option, #[serde(skip_serializing_if = "Option::is_none")] pub total_armor: Option, #[serde(skip_serializing_if = "Option::is_none")] pub canon: Option, #[serde(skip_serializing_if = "Option::is_none")] pub invalid: Option, #[serde(skip_serializing_if = "Option::is_none")] pub omni: Option, #[serde(skip_serializing_if = "Option::is_none")] pub clan: Option, } impl Columns for Figures { const CHUNK: Chunk = Chunk::Figures; fn take(unit: &UnitFacets, extra: &Extras) -> Self { Figures { tons: unit.tons, battle_value: unit.battle_value, cost: unit.cost, weight_class: unit.weight_class, total_armor: extra.total_armor, canon: unit.canon, invalid: unit.invalid, omni: unit.omni, clan: unit.clan, } } fn put(self, unit: &mut UnitFacets, extra: &mut Extras) { unit.tons = self.tons; unit.battle_value = self.battle_value; unit.cost = self.cost; unit.weight_class = self.weight_class; extra.total_armor = self.total_armor; unit.canon = self.canon; unit.invalid = self.invalid; unit.omni = self.omni; unit.clan = self.clan; } } /// Where a design comes from. #[derive(Debug, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Paperwork { #[serde(skip_serializing_if = "Option::is_none")] pub year: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tech_base: Option, #[serde(skip_serializing_if = "Option::is_none")] pub role: Option, #[serde(skip_serializing_if = "Option::is_none")] pub rules_level: Option, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(skip_serializing_if = "Vec::is_empty")] pub sources: Vec, } impl Columns for Paperwork { const CHUNK: Chunk = Chunk::Paperwork; fn take(unit: &UnitFacets, _extra: &Extras) -> Self { Paperwork { year: unit.year, tech_base: unit.tech_base.clone(), role: unit.role.clone(), rules_level: unit.rules_level.clone(), source: unit.source.clone(), sources: unit.sources.clone(), } } fn put(self, unit: &mut UnitFacets, _extra: &mut Extras) { unit.year = self.year; unit.tech_base = self.tech_base; unit.role = self.role; unit.rules_level = self.rules_level; unit.source = self.source; unit.sources = self.sources; } } /// How a design fights. #[derive(Debug, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Combat { #[serde(skip_serializing_if = "Option::is_none")] pub walk_mp: Option, #[serde(skip_serializing_if = "Option::is_none")] pub run_mp: Option, #[serde(skip_serializing_if = "Option::is_none")] pub jump_mp: Option, #[serde(skip_serializing_if = "Option::is_none")] pub firepower: Option, #[serde(skip_serializing_if = "Option::is_none")] pub max_range: Option, #[serde(skip_serializing_if = "Option::is_none")] pub heat: Option, #[serde(skip_serializing_if = "Option::is_none")] pub dissipation: Option, #[serde(skip_serializing_if = "Option::is_none")] pub heat_efficiency: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ammo_dependence: Option, #[serde(skip_serializing_if = "Option::is_none")] pub one_shot: Option, #[serde(skip_serializing_if = "Option::is_none")] pub armor_pct: Option, #[serde(skip_serializing_if = "Option::is_none")] pub melee: Option, #[serde(skip_serializing_if = "Option::is_none")] pub symmetrical: Option, } impl Columns for Combat { const CHUNK: Chunk = Chunk::Combat; fn take(unit: &UnitFacets, _extra: &Extras) -> Self { Combat { walk_mp: unit.walk_mp, run_mp: unit.run_mp, jump_mp: unit.jump_mp, firepower: unit.firepower, max_range: unit.max_range, heat: unit.heat, dissipation: unit.dissipation, heat_efficiency: unit.heat_efficiency, ammo_dependence: unit.ammo_dependence, one_shot: unit.one_shot, armor_pct: unit.armor_pct, melee: unit.melee, symmetrical: unit.symmetrical, } } fn put(self, unit: &mut UnitFacets, _extra: &mut Extras) { unit.walk_mp = self.walk_mp; unit.run_mp = self.run_mp; unit.jump_mp = self.jump_mp; unit.firepower = self.firepower; unit.max_range = self.max_range; unit.heat = self.heat; unit.dissipation = self.dissipation; unit.heat_efficiency = self.heat_efficiency; unit.ammo_dependence = self.ammo_dependence; unit.one_shot = self.one_shot; unit.armor_pct = self.armor_pct; unit.melee = self.melee; unit.symmetrical = self.symmetrical; } } /// What each design does at each hex from 1 to `helm_core::REACH`. #[derive(Debug, Default, Serialize, Deserialize)] #[serde(transparent)] pub struct Curve(pub Vec); impl Columns for Curve { const CHUNK: Chunk = Chunk::Curve; fn take(unit: &UnitFacets, _extra: &Extras) -> Self { Curve(unit.curve.clone()) } fn put(self, unit: &mut UnitFacets, _extra: &mut Extras) { unit.curve = self.0; } } /// MegaMek's per-design quirks. #[derive(Debug, Default, Serialize, Deserialize)] #[serde(transparent)] pub struct Quirks(pub Vec); impl Columns for Quirks { const CHUNK: Chunk = Chunk::Quirks; fn take(unit: &UnitFacets, _extra: &Extras) -> Self { Quirks(unit.quirks.clone()) } fn put(self, unit: &mut UnitFacets, _extra: &mut Extras) { unit.quirks = self.0; } } /// What each design carries, as MegaMek lists it. #[derive(Debug, Default, Serialize, Deserialize)] #[serde(transparent)] pub struct Loadout(pub Vec<(String, i64)>); impl Columns for Loadout { const CHUNK: Chunk = Chunk::Loadout; fn take(unit: &UnitFacets, _extra: &Extras) -> Self { Loadout(unit.loadout.clone()) } fn put(self, unit: &mut UnitFacets, _extra: &mut Extras) { unit.loadout = self.0; } } /// How much of the library is answered, which is not the same as how large it /// is. #[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct Counts { pub units: usize, /// Designs no producer could score. A screen showing none of them is /// showing a shorter library than it says. pub without_battle_value: usize, /// Designs MegaMek draws with a default silhouette. pub without_sprite: usize, } /// The spine: which designs there are, and where the rest of them is. #[derive(Serialize, Deserialize)] pub struct Index { pub build: Build, /// The release, again, at the top level. /// /// Where it was before there was a build block, and where headquarters-api /// still reads it: that service refuses an index it cannot find a /// `megamek` in and falls back to drawing silhouettes. Written until it /// reads `build.megamek` instead, and no longer. #[serde(default, skip_serializing_if = "String::is_empty")] pub megamek: String, /// The same, for the helm build. See `megamek` above. #[serde(default, skip_serializing_if = "Option::is_none")] pub helm: Option, /// Relative to the release, and the prefix a sprite path is joined to. /// The bucket holds the art tree at this path under the same version, so /// a consumer needs no second convention to turn one into an address. pub sprite_base: String, pub counts: Counts, /// The files published beside this one, so a reader discovers them rather /// than being told about them by whoever wrote its fetching code. pub chunks: Vec, /// The designs, in the order every chunk repeats. #[serde(with = "spine")] pub units: Vec<(UnitFacets, Extras)>, } /// Only the spine's own columns cross, which is what makes a chunk worth /// having: the rest of each unit is filled in when its chunk arrives. mod spine { use super::{Extras, Spine, UnitFacets}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; pub fn serialize( units: &[(UnitFacets, Extras)], out: S, ) -> Result { let rows: Vec = units .iter() .map(|(unit, _)| Spine { name: unit.name.clone(), chassis: unit.chassis.clone(), model: unit.model.clone(), unit_type: unit.unit_type.clone(), config: unit.config.clone(), mul_id: unit.mul_id, }) .collect(); rows.serialize(out) } pub fn deserialize<'de, D: Deserializer<'de>>( input: D, ) -> Result, D::Error> { Ok(Vec::::deserialize(input)? .into_iter() .map(|row| { ( UnitFacets { name: row.name, chassis: row.chassis, model: row.model, unit_type: row.unit_type, config: row.config, mul_id: row.mul_id, ..Default::default() }, Extras::default(), ) }) .collect()) } } /// Why a chunk could not be put onto a spine. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Mismatch { /// The two files are about different runs of helm. DifferentBuild { index: Box, chunk: Box, }, /// The same run, and one of the files is truncated. DifferentLength { index: usize, chunk: usize }, /// A file of the right shape and the wrong contents: art read as quirks. DifferentChunk { wanted: Chunk, got: Chunk }, } impl std::fmt::Display for Mismatch { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Mismatch::DifferentBuild { index, chunk } => { write!(f, "this index is {index} and that chunk is {chunk}") } Mismatch::DifferentLength { index, chunk } => write!( f, "{index} designs are indexed and {chunk} are in the chunk" ), Mismatch::DifferentChunk { wanted, got } => write!( f, "that is the {} chunk, not the {}", got.name(), wanted.name() ), } } } impl std::error::Error for Mismatch {} impl Index { /// The units, as the predicate wants them. pub fn facets(&self) -> Vec<&UnitFacets> { self.units.iter().map(|(unit, _)| unit).collect() } /// What a screen draws that no predicate reads. pub fn extras(&self) -> Vec<&Extras> { self.units.iter().map(|(_, extra)| extra).collect() } /// The chunk this run published, for one group of columns. pub fn part(&self) -> Part { Part { build: self.build.clone(), chunk: C::CHUNK, rows: self .units .iter() .map(|(unit, extra)| C::take(unit, extra)) .collect(), } } /// Put one group of columns onto the designs it belongs to, or say why the /// two files are not about the same run. /// /// A positional join is right or it is nonsense: nothing about the fifth /// row of one file says whether it is the fifth design of the other. So /// this is the only way the two are put together. pub fn join(&mut self, part: Part) -> Result<(), Mismatch> { if part.chunk != C::CHUNK { return Err(Mismatch::DifferentChunk { wanted: C::CHUNK, got: part.chunk, }); } if !self.build.joins(&part.build) { return Err(Mismatch::DifferentBuild { index: Box::new(self.build.clone()), chunk: Box::new(part.build), }); } if self.units.len() != part.rows.len() { return Err(Mismatch::DifferentLength { index: self.units.len(), chunk: part.rows.len(), }); } for ((unit, extra), columns) in self.units.iter_mut().zip(part.rows) { columns.put(unit, extra); } Ok(()) } } #[cfg(test)] mod tests { use super::*; fn build(names: &[&str]) -> Build { Build::new( "0.51.0", None, "megamek-bridge", None, names.iter().copied(), ) } /// One design with every column set to something distinguishable, written /// out in full. /// /// The literal is the point: it names all of them, so a facet added to /// `UnitFacets` fails to compile here until somebody has decided which /// chunk publishes it. That is the whole guarantee that splitting the /// index does not quietly drop a column. fn a_unit() -> (UnitFacets, Extras) { ( UnitFacets { name: "Atlas AS7-D".into(), chassis: "Atlas".into(), model: "AS7-D".into(), unit_type: Some("Mek".into()), config: Some("Biped".into()), mul_id: Some(87), tech_base: Some("Inner Sphere".into()), role: Some("Juggernaut".into()), source: Some("TR:3039,TR:SW".into()), sources: vec!["TR:3039".into(), "TR:SW".into()], rules_level: Some("Standard".into()), year: Some(2755), weight_class: Some(4), tons: Some(100.0), battle_value: Some(1897), cost: Some(9_000_000), canon: Some(true), invalid: Some(false), omni: Some(false), clan: Some(false), melee: Some(true), symmetrical: Some(false), walk_mp: Some(3), run_mp: Some(5), jump_mp: Some(0), firepower: Some(43.5), max_range: Some(21), heat: Some(28), dissipation: Some(20), heat_efficiency: Some(-8), ammo_dependence: Some(62), one_shot: Some(0), armor_pct: Some(93), curve: vec![25; 24], quirks: vec!["battle_fists".into()], loadout: vec![("Autocannon/20".into(), 1), ("Medium Laser".into(), 4)], }, Extras { sprite: Some("meks/atlas.png".into()), total_armor: Some(307), }, ) } fn index(units: Vec<(UnitFacets, Extras)>) -> Index { let names: Vec<&str> = units.iter().map(|(u, _)| u.name.as_str()).collect(); Index { build: build(&names), megamek: "0.51.0".into(), helm: None, sprite_base: "data/images/units".into(), counts: Counts::default(), chunks: Chunk::ALL.iter().map(|c| c.file()).collect(), units, } } /// Split the whole set out and put it all back: what comes out the far /// side is what went in, column for column. #[test] fn every_column_survives_the_split() { let whole = index(vec![a_unit()]); let (art, figures, paperwork, combat, curve, quirks, loadout) = ( whole.part::(), whole.part::(), whole.part::(), whole.part::(), whole.part::(), whole.part::(), whole.part::(), ); // Nothing but the spine crosses in the index itself. let text = serde_json::to_string(&whole).expect("write"); let mut read: Index = serde_json::from_str(&text).expect("read"); assert_eq!(read.units[0].0.battle_value, None, "a chunk column crossed"); assert_eq!(read.units[0].0.name, "Atlas AS7-D"); for part in [ serde_json::to_string(&art).unwrap(), serde_json::to_string(&figures).unwrap(), serde_json::to_string(&paperwork).unwrap(), serde_json::to_string(&combat).unwrap(), serde_json::to_string(&curve).unwrap(), serde_json::to_string(&quirks).unwrap(), serde_json::to_string(&loadout).unwrap(), ] { let chunk: serde_json::Value = serde_json::from_str(&part).unwrap(); match Chunk::named(chunk["chunk"].as_str().unwrap()).unwrap() { Chunk::Art => read.join(serde_json::from_str::>(&part).unwrap()), Chunk::Figures => read.join(serde_json::from_str::>(&part).unwrap()), Chunk::Paperwork => { read.join(serde_json::from_str::>(&part).unwrap()) } Chunk::Combat => read.join(serde_json::from_str::>(&part).unwrap()), Chunk::Curve => read.join(serde_json::from_str::>(&part).unwrap()), Chunk::Quirks => read.join(serde_json::from_str::>(&part).unwrap()), Chunk::Loadout => read.join(serde_json::from_str::>(&part).unwrap()), } .expect("the same run"); } assert_eq!(read.units[0], a_unit()); } /// Two files from different runs line up row for row and mean different /// designs. Refused, because the alternative is a page telling somebody a /// Locust carries a Gauss rifle. #[test] fn a_chunk_from_another_run_is_refused() { let mut whole = index(vec![a_unit()]); let part = Part { build: build(&["Locust LCT-1V"]), chunk: Chunk::Loadout, rows: vec![Loadout(vec![("ISGaussRifle".into(), 1)])], }; let err = whole.join(part).expect_err("a different run"); assert!(matches!(err, Mismatch::DifferentBuild { .. }), "{err}"); assert_eq!(whole.units[0].0.loadout, a_unit().0.loadout); } /// Nothing about the shape of a chunk says which chunk it is - art and /// quirks are both one value per design, and today they are told apart by /// luck rather than by design. The name written in the file is what makes /// that a refusal instead of a silent misreading. #[test] fn a_chunk_read_as_another_is_refused() { let mut whole = index(vec![a_unit()]); let mislabelled = Part { build: whole.build.clone(), chunk: Chunk::Art, rows: vec![Quirks(vec!["battle_fists".into()])], }; let err = whole .join(mislabelled) .expect_err("that says it is the art"); assert_eq!( err.to_string(), "that is the art chunk, not the quirks", "{err}" ); } /// Absent is the common case and an empty column is not worth its name: a /// design with no computed record writes three fields, not thirty-one. #[test] fn an_empty_column_is_not_written() { let whole = index(vec![( UnitFacets { name: "Locust LCT-1V".into(), ..Default::default() }, Extras::default(), )]); let text = serde_json::to_string(&whole.part::()).unwrap(); assert!(text.ends_with(r#""rows":[{}]}"#), "{text}"); } }