//! MegaMek's unit library, as the deployed index describes it. //! //! A match report wants a picture of each machine that fought, and the only //! thing in the result document that identifies a design is its name - //! "Thunderbolt TDR-5S". Nothing in a MegaMek install says which file that //! design is drawn with either: the mapping lives in `mekset.txt`, whose //! rules are subtle enough (uppercased comparisons, a chassis fallback, a //! Clan design's second name folded into the key) that a second reading of //! it is a second set of bugs. //! //! helm already reads it. `helm index` writes the reduced view of a release's //! library that a force-building screen filters on, and it resolves the //! sprite for every design along with the release the whole file describes. //! A recent helm publishes that library split: `units.json` is the spine and //! each group of columns is a document beside it, so the pictures are //! `art.json` joined to the spine by position. Both layouts are read here, //! because helm never deletes a prefix and this service and helm deploy //! separately. //! infra publishes one per release with `scripts/assets-helm.sh`, beside the //! art it names: //! //! https://lance.blue/assets/helm/0.51.0/units.json //! https://lance.blue/assets/megamek/0.51.0/data/images/units/meks/atlas.png //! //! So this module fetches that file once at startup and keeps the two columns //! a report needs. The version is taken from the index rather than pinned //! here: an index says which release it describes, and the art it points at //! is published under the same one, so there is no constant to keep in step. //! //! Meks only, today. The index is 4,279 designs and every one of them is a //! Mek, so a vehicle, a platoon or a fighter resolves to nothing here and the //! report falls back to the silhouette it has always drawn. That is why //! `share::sprite_for` is still load-bearing rather than dead. use std::collections::HashMap; use std::sync::{Arc, Mutex}; /// Where a release's own files are served from, under the site's origin. The /// same prefix `web/src/megamek.ts` builds, and for the same reason: it is /// one join between "a path inside a MegaMek install" and "a URL this site /// serves", and it belongs in one place per language. const ART_PREFIX: &str = "assets/megamek"; /// The most a unit sprite is allowed to weigh. They are 84x72 and run to a /// few kilobytes; this is here so a wrong address that answers with something /// enormous is dropped rather than decoded. const MAX_ART: usize = 512 * 1024; /// What a report needs out of helm's index, and the art behind it. pub struct UnitIndex { /// The MegaMek release the index describes, which is also the one its /// art is published under. version: String, /// The helm build that wrote the index, when it said. Reported to the /// site so a screen reads the same library this service does. helm: Option, /// Where a sprite path is relative to, inside the install: /// `data/images/units`. sprite_base: String, /// Design name to sprite path, e.g. "Atlas AS7-D" -> "meks/atlas.png". by_name: HashMap, /// Where the art is served from - the site's origin, not this service's. origin: String, http: reqwest::Client, /// Sprite path to its bytes. A release's art is written once and never /// changes, so an entry is good for the life of the process. /// /// Failures are not cached. A miss here means the card falls back to a /// silhouette for one draw; caching that would make a single bad minute /// permanent for as long as the process runs. art: Mutex>>>, } /// The shape of `units.json`, reduced to the two columns used here. #[derive(serde::Deserialize)] struct Document { megamek: String, /// The helm build that wrote this file. helm keys its artifacts by both /// ids, so this is half of the pair that names the library - and the /// file says it itself, which is why nothing here has to parse a URL. helm: Option, sprite_base: String, /// What built the library, when the file says. Every document helm /// writes in one go carries the same digest, which is what makes the /// join to a side document by position safe. Absent in a prefix written /// before helm split the index. build: Option, /// The side documents this index is published with, where it is one that /// was split into them. Absent in a prefix written before the split, /// which is what tells the two layouts apart. #[serde(default)] chunks: Vec, units: Vec, } #[derive(serde::Deserialize)] struct Entry { name: String, /// The design's picture, on a prefix written before helm split the /// index. On a split one the column is `art.json` instead, and this is /// absent for every design. sprite: Option, } /// What built one of helm's documents. Only the digest is read: two /// documents that agree on it describe the same designs in the same order. #[derive(serde::Deserialize)] struct Build { id: String, } /// One of helm's side documents: the same designs, in the same order. #[derive(serde::Deserialize)] struct Chunk { build: Option, chunk: String, rows: Vec>, } impl UnitIndex { /// Fetches and parses the index. `None` on any failure, which leaves the /// report drawing silhouettes - see `load`. pub async fn fetch(url: &str, origin: &str) -> Option { let http = reqwest::Client::new(); let document: Document = get_json(&http, url, "unit index").await?; // A split index keeps the pictures in `art.json` and the spine // carries none, so the column is read from there instead. Asked for // only when the spine has nothing, which is what keeps an old prefix // one request. let by_name: HashMap = if document.units.iter().any(|unit| unit.sprite.is_some()) { document .units .into_iter() .filter_map(|unit| Some((unit.name, unit.sprite?))) .collect() } else { let art = art_column(&http, url, &document).await?; document .units .into_iter() .zip(art) .filter_map(|(unit, sprite)| Some((unit.name, sprite?))) .collect() }; if by_name.is_empty() { tracing::warn!("unit index: no unit carries a sprite"); return None; } tracing::info!( megamek = %document.megamek, units = by_name.len(), "unit index: loaded" ); Some(Self { version: document.megamek, helm: document.helm, sprite_base: document.sprite_base, by_name, origin: origin.to_owned(), http, art: Mutex::new(HashMap::new()), }) } /// The address of one file under the release's `data/images/units`. /// /// `sprite` is a path relative to that, which is the form both halves of /// the library take: what helm resolved for a design, and what MegaMek's /// own `mekset.txt` names its defaults. /// /// Built through `Url` rather than by formatting, because 162 of the /// sprite filenames in 0.51.0 have a space in them and the bucket is /// case- and byte-sensitive: `Battle Hawk.png` has to go out as /// `Battle%20Hawk.png` or it is a 404 instead of a picture. pub fn sprite_url(&self, sprite: &str) -> Option { let mut url = reqwest::Url::parse(&self.origin).ok()?; { let mut path = url.path_segments_mut().ok()?; for segment in ART_PREFIX .split('/') .chain(std::iter::once(self.version.as_str())) .chain(self.sprite_base.split('/')) .chain(sprite.split('/')) { path.push(segment); } } Some(url.into()) } /// Which library this is: the MegaMek it describes and the helm build /// that wrote it. /// /// Reported on `/api/version` so the site reads the same one. The site /// used to name a helm release in its own source, which meant a helm /// deploy needed a site build to match it - and when it did not get one, /// the two read different indexes and neither said so. pub fn library(&self) -> (&str, Option<&str>) { (&self.version, self.helm.as_deref()) } /// The sprites for a batch of designs, for the screens that draw a /// handful and should not fetch the whole index to do it. /// /// Names the index does not carry are left out rather than answered with /// a null: the caller is asking what this library knows, and a design it /// has never heard of is not a fact about that design. pub fn sprites_for<'a>( &self, names: impl IntoIterator, ) -> std::collections::BTreeMap<&str, &str> { names .into_iter() .filter_map(|name| { let (key, sprite) = self.by_name.get_key_value(name)?; Some((key.as_str(), sprite.as_str())) }) .collect() } /// The sprite a design is drawn with, or `None` for one the index does /// not carry - every vehicle, platoon and fighter, today. pub fn design(&self, name: &str) -> Option<&str> { self.by_name.get(name).map(String::as_str) } /// One sprite's bytes, for the drawn card. `None` for art that would not /// come down, in which case the caller draws the machine without a /// picture rather than not at all. pub async fn sprite(&self, sprite: &str) -> Option>> { let sprite = sprite.to_owned(); if let Some(found) = self .art .lock() .expect("art cache is not poisoned") .get(&sprite) { return Some(Arc::clone(found)); } let url = self.sprite_url(&sprite)?; // Two cards drawn at once for the same design fetch it twice and the // second insert wins. Both are the same bytes, so the race costs one // request rather than correctness. let bytes = match self.http.get(&url).send().await { Ok(response) if response.status().is_success() => response.bytes().await.ok()?, Ok(response) => { tracing::warn!(status = %response.status(), sprite, "unit art: refused"); return None; } Err(e) => { tracing::warn!(sprite, "unit art: fetch failed: {e}"); return None; } }; if bytes.len() > MAX_ART { tracing::warn!(sprite, size = bytes.len(), "unit art: too large"); return None; } let art = Arc::new(bytes.to_vec()); self.art .lock() .expect("art cache is not poisoned") .insert(sprite, Arc::clone(&art)); Some(art) } } /// One of helm's documents, fetched and parsed. `None` on any failure, all /// of which are the same thing to a caller: this deployment has no index and /// the report draws silhouettes. async fn get_json( http: &reqwest::Client, url: &str, what: &str, ) -> Option { let response = match http.get(url).send().await { Ok(response) => response, Err(e) => { tracing::warn!(url, "{what}: fetch failed: {e}"); return None; } }; if !response.status().is_success() { tracing::warn!(status = %response.status(), url, "{what}: refused"); return None; } let body = match response.bytes().await { Ok(body) => body, Err(e) => { tracing::warn!(url, "{what}: read failed: {e}"); return None; } }; match serde_json::from_slice(&body) { Ok(parsed) => Some(parsed), Err(e) => { tracing::warn!(url, "{what}: not the expected format: {e}"); None } } } /// The address of a side document beside the index it belongs to. /// /// `None` where the index is not named `units.json`, which is the only /// address helm publishes a split library under - a chunk of a file whose /// name this does not recognise would be a guess. fn chunk_url(index_url: &str, name: &str) -> Option { let stem = index_url.strip_suffix("units.json")?; Some(format!("{stem}{name}.json")) } /// helm's `art.json`: design *n* of the spine is drawn with row *n* of this. /// /// `None` rather than an empty column for anything that is not the document /// asked for. A wrong or stale chunk joined by position would give designs /// each other's pictures, which is worse than the silhouettes an absent /// index draws. async fn art_column( http: &reqwest::Client, index_url: &str, index: &Document, ) -> Option>> { // Both spellings, because the manifest names the files and the join uses // the column's own name. if !index .chunks .iter() .any(|chunk| chunk == "art" || chunk == "art.json") { tracing::warn!("unit index: no sprites on the spine and no art document"); return None; } let url = chunk_url(index_url, "art")?; let art: Chunk = get_json(http, &url, "unit art index").await?; let rows = joined(index, art); if rows.is_none() { tracing::warn!(url, "unit index: the art document does not belong to it"); } rows } /// The art column, if it is this index's own. /// /// Both halves of the identity are checked, because either alone can be /// right by accident: two builds of the same library have the same length, /// and a document of the right length could still be the wrong column. fn joined(index: &Document, art: Chunk) -> Option>> { if art.chunk != "art" || art.rows.len() != index.units.len() { return None; } if let Some(build) = index.build.as_ref() && art.build.as_ref().map(|b| b.id.as_str()) != Some(build.id.as_str()) { return None; } Some(art.rows) } /// An index standing in for a fetched one, so a page test can say what a /// resolved machine looks like without a network. #[cfg(test)] pub fn for_test(units: &[(&str, &str)]) -> UnitIndex { UnitIndex { version: "0.51.0".to_owned(), helm: Some("main-0bb2bf14a1a3".to_owned()), sprite_base: "data/images/units".to_owned(), by_name: units .iter() .map(|(name, sprite)| ((*name).to_owned(), (*sprite).to_owned())) .collect(), origin: "https://lance.blue".to_owned(), http: reqwest::Client::new(), art: Mutex::new(HashMap::new()), } } /// Loads the index if this deployment names one. /// /// Never fatal. An API that will not start because a CDN was slow is worse /// than one whose report pages draw the silhouettes they drew before this /// existed, and development names no index at all. pub async fn load(url: Option<&str>, origin: &str) -> Option> { let url = url?; let index = UnitIndex::fetch(url, origin).await.map(Arc::new); if index.is_none() { tracing::warn!("unit index: unavailable; reports will draw silhouettes"); } index } #[cfg(test)] mod tests { use super::*; use super::for_test as index; /// The address is the site's, the release comes from the index, and the /// path is the one the file has inside the install. #[test] fn art_url_is_the_published_address() { let index = index(&[("Atlas AS7-D", "meks/atlas.png")]); assert_eq!( index .sprite_url(index.design("Atlas AS7-D").unwrap()) .unwrap(), "https://lance.blue/assets/megamek/0.51.0/data/images/units/meks/atlas.png" ); } /// 162 sprites in 0.51.0 have a space in the filename. Sent literally /// they are a 404, so the escape is the difference between a picture and /// a broken image. #[test] fn a_space_in_a_filename_is_escaped() { let index = index(&[("Battle Hawk BH-K305", "meks/Battle Hawk.png")]); assert_eq!( index .sprite_url(index.design("Battle Hawk BH-K305").unwrap()) .unwrap(), "https://lance.blue/assets/megamek/0.51.0/data/images/units/meks/Battle%20Hawk.png" ); } /// A design the index does not carry - every vehicle, platoon and /// fighter today - resolves to nothing rather than to a guess. #[test] fn an_unknown_design_has_no_address() { let index = index(&[("Atlas AS7-D", "meks/atlas.png")]); assert!(index.design("Demolisher Heavy Tank").is_none()); } /// helm's spine, as it is published today: no `sprite` anywhere on it, /// and the pictures in a document beside it. const SPINE: &str = r#"{ "build": {"id": "79929ce6d4afec9d"}, "megamek": "0.51.0", "helm": "main-8faab29cbd6f", "sprite_base": "data/images/units", "chunks": ["art.json", "figures.json"], "units": [{"name": "Atlas AS7-D"}, {"name": "'Battle Tripod' R/H3L-2X"}] }"#; fn spine() -> Document { serde_json::from_str(SPINE).expect("the spine parses") } fn art(json: &str) -> Chunk { serde_json::from_str(json).expect("the chunk parses") } /// The join is by position, and a design helm has no picture for keeps /// its hole rather than taking its neighbour's. #[test] fn the_art_document_joins_by_position() { let rows = joined( &spine(), art(r#"{"build": {"id": "79929ce6d4afec9d"}, "chunk": "art", "rows": ["meks/atlas.png", null]}"#), ) .expect("the document belongs to the index"); assert_eq!(rows, vec![Some("meks/atlas.png".to_owned()), None]); } /// Same library, different build: the same length and the same columns, /// and rows that may be in another order. Refused rather than joined - /// designs wearing each other's pictures is worse than silhouettes. #[test] fn art_from_another_build_is_refused() { assert!( joined( &spine(), art(r#"{"build": {"id": "0000000000000000"}, "chunk": "art", "rows": ["meks/atlas.png", null]}"#), ) .is_none() ); } /// A document of the right length can still be the wrong column. #[test] fn another_column_is_refused() { assert!( joined( &spine(), art( r#"{"build": {"id": "79929ce6d4afec9d"}, "chunk": "paperwork", "rows": ["meks/atlas.png", null]}"# ), ) .is_none() ); } /// A short document is not this library's. #[test] fn a_short_art_document_is_refused() { assert!( joined( &spine(), art(r#"{"build": {"id": "79929ce6d4afec9d"}, "chunk": "art", "rows": ["meks/atlas.png"]}"#), ) .is_none() ); } /// The side documents sit beside the index, under the same prefix, so a /// prefix is never named twice. #[test] fn a_chunk_sits_beside_its_index() { assert_eq!( chunk_url( "https://lance.blue/assets/helm/main-8faab29cbd6f/0.51.0/units.json", "art" ) .as_deref(), Some("https://lance.blue/assets/helm/main-8faab29cbd6f/0.51.0/art.json") ); assert!(chunk_url("https://lance.blue/assets/helm/index.json", "art").is_none()); } /// A prefix written before the split carries the column on the spine and /// has no side documents at all. Still read, because helm never deletes /// a prefix and this service is deployed separately from it. #[test] fn the_old_layout_still_parses() { let old: Document = serde_json::from_str( r#"{"megamek": "0.51.0", "helm": "main-bf06475572ec", "sprite_base": "data/images/units", "units": [{"name": "Atlas AS7-D", "sprite": "meks/atlas.png"}]}"#, ) .expect("the old spine parses"); assert!(old.chunks.is_empty()); assert_eq!(old.units[0].sprite.as_deref(), Some("meks/atlas.png")); } /// MegaMek's own default silhouettes go down the same pipeline as a /// design's art: a path under the release's `data/images/units`, and the /// same join to a URL. There is one library, so there is one address /// rule - nothing about a machine's picture is committed in this repo. #[test] fn a_default_silhouette_is_addressed_the_same_way() { let index = index(&[]); assert_eq!( index.sprite_url("defaults/default_medium.png").unwrap(), "https://lance.blue/assets/megamek/0.51.0/data/images/units/defaults/default_medium.png" ); } /// The index's own release, not one written down here. #[test] fn the_version_comes_from_the_document() { let document: Document = serde_json::from_str( r#"{"megamek":"0.52.1","sprite_base":"data/images/units", "units":[{"name":"Atlas AS7-D","sprite":"meks/atlas.png"}]}"#, ) .unwrap(); assert_eq!(document.megamek, "0.52.1"); assert_eq!(document.units[0].sprite.as_deref(), Some("meks/atlas.png")); } /// A unit with no sprite is dropped rather than carried as an empty /// address. `helm index` reports `without_sprite` for exactly this. #[test] fn a_unit_without_a_sprite_is_dropped() { let document: Document = serde_json::from_str( r#"{"megamek":"0.51.0","sprite_base":"data/images/units", "units":[{"name":"A","sprite":null},{"name":"B","sprite":"meks/b.png"}]}"#, ) .unwrap(); let by_name: HashMap = document .units .into_iter() .filter_map(|unit| Some((unit.name, unit.sprite?))) .collect(); assert_eq!(by_name.len(), 1); assert!(by_name.contains_key("B")); } }