Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
23 kB · 582 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583//! 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<String>, /// 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<String, String>, /// 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<HashMap<String, Arc<Vec<u8>>>>,}
/// 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<String>, 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<Build>, /// 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<String>, units: Vec<Entry>,}
#[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<String>,}
/// 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<Build>, chunk: String, rows: Vec<Option<String>>,}
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<Self> { 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<String, String> = 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<String> { 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<Item = &'a str>, ) -> 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<Arc<Vec<u8>>> { 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<T: serde::de::DeserializeOwned>( http: &reqwest::Client, url: &str, what: &str,) -> Option<T> { 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<String> { 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<Vec<Option<String>>> { // 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<Vec<Option<String>>> { 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<Arc<UnitIndex>> { 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<String, String> = 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")); }}