diff --git a/.env.example b/.env.example index 72a637f..a343cf7 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,13 @@ # /api/flare answers that flares are not enabled. # FLARE_SPACE=at://did:plc:a2j2g42ai6v65qpbvb6hmubi/app.userinput.space/3msr5yrvtq22g +# helm's index of MegaMek's unit library, which is what tells a match report +# the picture each design is drawn with. Read once at startup; unset, or +# unreachable, and a report draws each machine as its weight class instead. +# The release and the address of its art both come out of the file, so this +# names which index and nothing else. +# UNIT_INDEX_URL=https://lance.blue/assets/helm/0.51.0/units.json + # --- confidential client (public deployments only) --------------------------- # Setting PUBLIC_URL switches to the confidential client: metadata served at # PUBLIC_URL/oauth/client-metadata.json, keys at /.well-known/jwks.json, diff --git a/plan/forces.md b/plan/forces.md index 616086d..4ce0359 100644 --- a/plan/forces.md +++ b/plan/forces.md @@ -91,6 +91,9 @@ tree says so. A screen that draws a unit needs an address, and beside the catalog. `web/src/scenario/unit-art.ts` names 61 units because the scenario catalog names 61; a force manager can offer 4,279, and the answer for each of them is a column helm already knows how to fill. + The index already carries the column — `helm index` reports + `without_sprite: 0` — and the API reads it (below). What is left is the + browser doing the same and `unit-art.ts` going away with it. - [ ] **Pin the version to what a match runs.** `MEGAMEK_VERSION` in `web/src/megamek.ts` is a constant today and arena's pin is the truth, so a bump is two edits with a sync between them. The index is stamped with @@ -206,4 +209,13 @@ where a player brings something. ## Done -Nothing closed yet. +- [x] **The match report reads the index.** `services/api/src/units.rs` + fetches `UNIT_INDEX_URL` once at startup and keeps two columns: a + design's name and the sprite helm resolved for it. Both halves of the + report draw from it — the page points an `` at + `/assets/megamek//`, and the card fetches the same bytes + and paints them in the force's camo, since MegaMek's art is 84x72 like + the silhouettes it replaces. The release is the index's own, so nothing + here names a version. Meks only, which is all the index holds, so + `share::sprite_for` stays for every vehicle, platoon and fighter — and + for a deployment that names no index at all. diff --git a/plan/post-to-bluesky.md b/plan/post-to-bluesky.md index bf6a6cf..8b6120f 100644 --- a/plan/post-to-bluesky.md +++ b/plan/post-to-bluesky.md @@ -273,8 +273,16 @@ this epic does not answer it. nothing this service can post on its own. That answers "decide what the link points at, and whether an unfurl exists for it"; everything above about posting from here still stands. +- [x] **Each machine is drawn as itself.** The report used to pick one of + nine silhouettes from kind and tonnage, so a Thunderbolt and a Crusader + came out the same picture on the same card - both 65-tonne bipeds. It + reads helm's published unit index now (`UNIT_INDEX_URL`, see + `plan/forces.md`), which names the file MegaMek itself draws each design + with. Page and card both, so what a reader sees and what the post + unfurls into agree. The silhouettes stay for what the index does not + hold, which today is everything that is not a Mek. - [x] **The page is worth arriving at.** The force card first - one small - silhouette per machine with MegaMek's own reading of how hurt it is - + picture per machine with MegaMek's own reading of how hurt it is - and a machine's full record sheet behind a click: armour, structure and critical slots per location, what is left in the ammo bins, the crew. The panel is `:target` rather than a script, because the reader is diff --git a/services/api/src/card.rs b/services/api/src/card.rs index ab9b5d9..1731fa9 100644 --- a/services/api/src/card.rs +++ b/services/api/src/card.rs @@ -82,8 +82,10 @@ const TEXT: &[u8] = include_bytes!("../assets/fonts/BarlowSemiCondensed-Medium.t /// One machine, as small as a card can say it. pub struct Machine<'a> { pub name: &'a str, - /// The silhouette's bytes, from the set the page serves. - pub sprite: &'static [u8], + /// The picture's bytes: the design's own art where the unit index + /// resolved it, and one of this service's weight-class silhouettes + /// otherwise. Both are 84x72, so what is drawn with them is the same. + pub sprite: std::borrow::Cow<'a, [u8]>, /// What became of it, in a word: "unhurt", "heavy damage", "destroyed". pub condition: &'a str, /// Whether it came home. Drawn struck through when it did not. @@ -1101,7 +1103,7 @@ fn draw_machines(canvas: &mut Canvas, force: &Force, x: i32, y: i32, room: i32, let Some(machine) = machines.next() else { break; }; - if let Some(sprite) = decode(machine.sprite) { + if let Some(sprite) = decode(&machine.sprite) { let painted = paint(&sprite, camo.as_ref()); draw_sprite( canvas, @@ -1297,7 +1299,7 @@ fn draw_duel_plate( if force.machines.len() <= 5 { let mut row_y = y + 62; for machine in &force.machines { - if let Some(sprite) = decode(machine.sprite) { + if let Some(sprite) = decode(&machine.sprite) { let painted = paint(&sprite, camo.as_ref()); draw_sprite(canvas, &painted, x + 18, row_y, 52, machine.alive); } @@ -1672,7 +1674,7 @@ fn draw_figure_force( let index = index as i32; let mx = x + (index % per_row) * (size + gap); let y = y + (index / per_row) * (size * 72 / 84 + 40); - if let Some(sprite) = decode(machine.sprite) { + if let Some(sprite) = decode(&machine.sprite) { let painted = paint(&sprite, camo.as_ref()); draw_sprite(canvas, &painted, mx, y, size, machine.alive); } diff --git a/services/api/src/config.rs b/services/api/src/config.rs index 7761ee2..5914aa9 100644 --- a/services/api/src/config.rs +++ b/services/api/src/config.rs @@ -38,6 +38,10 @@ pub struct Config { /// The `at://` URI of the userinput.app space flares are filed to. /// Absent, /api/flare answers that flares are not enabled. pub flare_space: Option, + /// helm's published unit index for the release matches run, e.g. + /// `https://lance.blue/assets/helm/0.51.0/units.json`. Absent, a match + /// report draws each machine as its weight class rather than as itself. + pub unit_index_url: Option, } #[derive(Debug, Clone)] @@ -252,6 +256,23 @@ impl Config { } }; + // Which index, and nothing more: the release it describes and where + // that release's art is published both come out of the file, so this + // is the only place a version is named and it is named once. + // Empty is unset, not a bad value: infra passes every entry of one + // env map, so "no index configured" arrives as `UNIT_INDEX_URL=""` + // rather than as an absent variable, and refusing to start over it + // would take the whole API down for a report detail. + let unit_index_url = match lookup("UNIT_INDEX_URL").filter(|url| !url.is_empty()) { + None => None, + Some(url) => { + if !url.starts_with("https://") && !url.starts_with("http://") { + return Err(format!("UNIT_INDEX_URL {url:?} must be an http(s) URL")); + } + Some(url) + } + }; + Ok(Config { bind_addr, public_url, @@ -267,6 +288,7 @@ impl Config { build_ref: lookup("BUILD_REF").filter(|s| !s.is_empty() && s != "unknown"), arena_version: lookup("ARENA_VERSION").filter(|s| !s.is_empty()), flare_space, + unit_index_url, }) } @@ -296,6 +318,30 @@ mod tests { const SECRET: &str = "0123456789abcdef0123456789abcdef"; + /// The one env map infra fills passes every key, so a deployment with no + /// index set sends an empty string. That has to mean "no index" and not + /// "refuse to start" - it is a detail of one page, and taking the API + /// down for it would be far worse than a silhouette. + #[test] + fn an_empty_unit_index_is_no_index() { + assert_eq!( + config(&[("UNIT_INDEX_URL", "")]).unwrap().unit_index_url, + None + ); + assert_eq!(config(&[]).unwrap().unit_index_url, None); + assert_eq!( + config(&[( + "UNIT_INDEX_URL", + "https://lance.blue/assets/helm/0.51.0/units.json" + )]) + .unwrap() + .unit_index_url + .as_deref(), + Some("https://lance.blue/assets/helm/0.51.0/units.json") + ); + assert!(config(&[("UNIT_INDEX_URL", "lance.blue/units.json")]).is_err()); + } + #[test] fn defaults_are_loopback_dev() { let c = config(&[]).unwrap(); diff --git a/services/api/src/main.rs b/services/api/src/main.rs index c7b29e6..0331024 100644 --- a/services/api/src/main.rs +++ b/services/api/src/main.rs @@ -10,6 +10,7 @@ mod proxy; mod routes; mod session; mod share; +mod units; use std::sync::Arc; @@ -81,6 +82,10 @@ async fn main() { let lobbies = Arc::new(matches::lobby::Lobbies::new(db.clone())); + // Fetched here rather than on the first report, so a deployment says at + // startup whether its pages will draw real machines or silhouettes. + let units = units::load(config.unit_index_url.as_deref(), &config.web_origin).await; + let state = AppState { atproto: Arc::new(atproto), lobbies, @@ -96,6 +101,7 @@ async fn main() { build_ref: config.build_ref.clone(), arena_version: config.arena_version.clone(), flare_space: config.flare_space.clone(), + units, }; let listener = tokio::net::TcpListener::bind(config.bind_addr) diff --git a/services/api/src/routes.rs b/services/api/src/routes.rs index 89e0880..afcf923 100644 --- a/services/api/src/routes.rs +++ b/services/api/src/routes.rs @@ -41,6 +41,10 @@ pub struct AppState { /// The at:// URI of the userinput.app space flares are filed to; None /// means /api/flare answers that flares are not enabled. pub flare_space: Option, + /// helm's index of the unit library, for the picture a report draws each + /// machine with. None where no index was named or one would not load, in + /// which case a report draws weight-class silhouettes as it always has. + pub units: Option>, } pub fn app(state: AppState) -> Router { @@ -2353,6 +2357,7 @@ mod tests { let db = crate::db::Db::open(&dir.path().join("test.sqlite")).unwrap(); let config = crate::config::Config::from_lookup(|_| None).unwrap(); let state = AppState { + units: None, atproto: Arc::new(Atproto::new(&config, db.clone()).unwrap()), lobbies: Arc::new(crate::matches::lobby::Lobbies::new(db.clone())), db, diff --git a/services/api/src/share.rs b/services/api/src/share.rs index 12a6cbf..d94e32c 100644 --- a/services/api/src/share.rs +++ b/services/api/src/share.rs @@ -1189,13 +1189,22 @@ fn lost(status: &str) -> bool { matches!(status, "destroyed" | "devastated" | "salvage" | "captured") } -/// One machine on the force card: its silhouette, its name, and how hurt it -/// is. The whole tile is the link into its record sheet. +/// One machine on the force card: its picture, its name, and how hurt it is. +/// The whole tile is the link into its record sheet. /// /// This is the view a reader starts on, and for most readers it is the only /// one they need - which of these machines came home, and in what state. The /// sheet behind it is for the reader who wants to know why. -fn unit_tile(id: &str, unit: &serde_json::Value) -> String { +/// +/// The picture is the design's own where the index knows it, which is every +/// Mek, and its weight class otherwise. That is the difference between a +/// Thunderbolt and a Crusader reading as two machines and reading as the same +/// 65-tonne biped twice. +fn unit_tile( + id: &str, + unit: &serde_json::Value, + units: Option<&crate::units::UnitIndex>, +) -> String { let name = unit .get("name") .and_then(serde_json::Value::as_str) @@ -1222,14 +1231,19 @@ fn unit_tile(id: &str, unit: &serde_json::Value) -> String { } else { format!("") }; + // MegaMek's own art, served from the site beside the release it belongs + // to, or this service's silhouette for a design the index does not carry. + let picture = units + .and_then(|index| index.art_url(name)) + .unwrap_or_else(|| format!("/reports/sprites/{}.png", sprite_for(unit))); format!( "\ - \"\"\ + \"\"\ {name}\ {figures}\ {condition}{bar}", tone = tone(unit), - sprite = sprite_for(unit), + picture = escape(&picture), name = escape(name), figures = figures.join(" · "), condition = escape(&condition(unit)), @@ -1746,6 +1760,7 @@ pub async fn page( offered: &offered, chosen, participant: ask.participant.is_some(), + units: state.units.as_deref(), }, )), ) @@ -1770,6 +1785,9 @@ struct Page<'a> { chosen: card::Layout, /// Whether to offer the composer at all. See `Ask::participant`. participant: bool, + /// The unit library, for a machine's own picture. None where this + /// deployment has no index, and then every machine draws as its class. + units: Option<&'a crate::units::UnitIndex>, } fn document(summary: &Summary, context: &Context, page: &Page) -> String { @@ -1807,7 +1825,7 @@ fn document(summary: &Summary, context: &Context, page: &Page) -> String { .units .iter() .enumerate() - .map(|(n, unit)| unit_tile(&unit_id(index, n), unit)) + .map(|(n, unit)| unit_tile(&unit_id(index, n), unit, page.units)) .collect(); let machines = if machines.is_empty() { String::new() @@ -1949,6 +1967,11 @@ pub async fn card( .filter(|wanted| offered.contains(wanted)) .unwrap_or(offered[0]); + // Each design's own picture, fetched before anything is drawn: the + // drawing kit is synchronous by design, and a machine the index does not + // carry simply keeps the silhouette it had. + let art = unit_art(state.units.as_deref(), &summary).await; + let verdict = verdict_line(&summary, perspective.as_ref()); let (title, sub) = headline(layout, &context, &verdict, summary.round); let designation = context.designation(layout); @@ -1965,6 +1988,7 @@ pub async fn card( perspective: perspective.as_ref(), camo: &camo, board: board.clone(), + art: &art, }, )); @@ -2090,6 +2114,100 @@ struct Drawn<'a> { /// One entry per force, in the summary's order. camo: &'a [Option>], board: Option>, + /// Design name to its own art, for the machines the unit index resolved. + /// Fetched before the draw, because drawing is not async and should not + /// become so to reach over the network mid-composition. + art: &'a std::collections::HashMap>>, +} + +/// Every design on the card, fetched once each. +/// +/// Distinct by name rather than per machine: a lance of four Locusts is one +/// request, not four. A design the index does not carry, and art that would +/// not come down, are both simply absent - `compose` falls back to the +/// silhouette for each, so a card is always drawn. +async fn unit_art( + units: Option<&crate::units::UnitIndex>, + summary: &Summary, +) -> std::collections::HashMap>> { + let mut art = std::collections::HashMap::new(); + let Some(index) = units else { + return art; + }; + let mut names: Vec<&str> = summary + .forces + .iter() + .flat_map(|force| force.units.iter()) + .filter_map(|unit| unit.get("name").and_then(serde_json::Value::as_str)) + .collect(); + names.sort_unstable(); + names.dedup(); + for name in names { + if let Some(bytes) = index.art(name).await { + art.insert(name.to_owned(), bytes); + } + } + art +} + +/// No art fetched, which is what every card assertion draws with: they are +/// about composition and the silhouettes are the stable input for that. The +/// fetch itself is `units::UnitIndex`'s to test. +#[cfg(test)] +fn no_art() -> std::collections::HashMap>> { + std::collections::HashMap::new() +} + +/// The same art a deployment would fetch, read off a local MegaMek install +/// instead of the network, so the `cards` test can be looked at the way the +/// card actually ships: +/// +/// CARD_UNITS=units.json CARD_MEGAMEK=~/.cache/mul-build/megamek \ +/// cargo test -p headquarters-api share::tests::cards -- --ignored +/// +/// `units.json` is `helm index`'s output - the same file infra publishes. +/// Neither variable set, this is empty and the card draws silhouettes. +#[cfg(test)] +fn local_art(summary: &Summary) -> std::collections::HashMap>> { + let mut art = std::collections::HashMap::new(); + let (Ok(index), Ok(install)) = (std::env::var("CARD_UNITS"), std::env::var("CARD_MEGAMEK")) + else { + return art; + }; + let Ok(document) = std::fs::read(&index) else { + return art; + }; + let Ok(document) = serde_json::from_slice::(&document) else { + return art; + }; + let base = document + .get("sprite_base") + .and_then(serde_json::Value::as_str) + .unwrap_or("data/images/units"); + let sprites: std::collections::HashMap<&str, &str> = document + .get("units") + .and_then(serde_json::Value::as_array) + .map(|units| { + units + .iter() + .filter_map(|unit| { + Some((unit.get("name")?.as_str()?, unit.get("sprite")?.as_str()?)) + }) + .collect() + }) + .unwrap_or_default(); + for unit in summary.forces.iter().flat_map(|force| force.units.iter()) { + let Some(name) = unit.get("name").and_then(serde_json::Value::as_str) else { + continue; + }; + let Some(sprite) = sprites.get(name) else { + continue; + }; + if let Ok(bytes) = std::fs::read(format!("{install}/{base}/{sprite}")) { + art.insert(name.to_owned(), std::sync::Arc::new(bytes)); + } + } + art } fn compose<'a>(summary: &'a Summary, drawn: Drawn<'a>) -> card::Card<'a> { @@ -2103,6 +2221,7 @@ fn compose<'a>(summary: &'a Summary, drawn: Drawn<'a>) -> card::Card<'a> { perspective, camo, board, + art, } = drawn; let forces = summary .forces @@ -2131,18 +2250,27 @@ fn compose<'a>(summary: &'a Summary, drawn: Drawn<'a>) -> card::Card<'a> { machines: force .units .iter() - .map(|unit| card::Machine { - name: unit + .map(|unit| { + let name = unit .get("name") .and_then(serde_json::Value::as_str) - .unwrap_or("Unknown machine"), - sprite: sprite_bytes(sprite_for(unit)), - condition: condition_word(unit), - alive: !lost( - unit.get("status") - .and_then(serde_json::Value::as_str) - .unwrap_or("active"), - ), + .unwrap_or("Unknown machine"); + card::Machine { + name, + // The design's own picture where it came down, and the + // silhouette for its weight class where it did not. + sprite: std::borrow::Cow::Borrowed( + art.get(name) + .map(|bytes| bytes.as_slice()) + .unwrap_or_else(|| sprite_bytes(sprite_for(unit))), + ), + condition: condition_word(unit), + alive: !lost( + unit.get("status") + .and_then(serde_json::Value::as_str) + .unwrap_or("active"), + ), + } }) .collect(), }) @@ -2554,6 +2682,7 @@ mod tests { offered: &offered, chosen: offered[0], participant: true, + units: None, }, ) } @@ -2578,6 +2707,118 @@ mod tests { ) } + /// A machine the index knows is drawn as itself; one it does not keeps + /// the silhouette for its weight class. Both halves matter: the index is + /// Meks only, so every vehicle and platoon still depends on the fallback. + #[test] + fn a_known_design_is_drawn_as_itself() { + let index = crate::units::for_test(&[("Thunderbolt TDR-5S", "meks/thunderbolt.png")]); + let thunderbolt = serde_json::json!({ + "name": "Thunderbolt TDR-5S", "kind": "mek", "tons": 65.0, "status": "active" + }); + let tile = unit_tile("u0-0", &thunderbolt, Some(&index)); + assert!( + tile.contains( + "https://lance.blue/assets/megamek/0.51.0/data/images/units/meks/thunderbolt.png" + ), + "the design's own art: {tile}" + ); + + let demolisher = serde_json::json!({ + "name": "Demolisher Heavy Tank", "kind": "tank", "tons": 80.0, "status": "active" + }); + let tile = unit_tile("u0-1", &demolisher, Some(&index)); + assert!( + tile.contains("/reports/sprites/demolisher.png"), + "the class silhouette: {tile}" + ); + } + + /// Two 65-tonne bipeds are one silhouette and two pictures. This is the + /// whole point of reading the index: without it a Thunderbolt and a + /// Crusader are the same image on the same card. + #[test] + fn two_designs_of_a_weight_are_two_pictures() { + let index = crate::units::for_test(&[ + ("Thunderbolt TDR-5S", "meks/thunderbolt.png"), + ("Crusader CRD-3R", "meks/Crusader.png"), + ]); + let mek = |name: &str| serde_json::json!({"name": name, "kind": "mek", "tons": 65.0, "status": "active"}); + assert_eq!( + sprite_for(&mek("Thunderbolt TDR-5S")), + sprite_for(&mek("Crusader CRD-3R")), + "the silhouettes were already distinct; this test proves nothing" + ); + assert_ne!( + unit_tile("u0-0", &mek("Thunderbolt TDR-5S"), Some(&index)), + unit_tile("u0-0", &mek("Crusader CRD-3R"), Some(&index)), + ); + } + + /// The drawn card paints the design's own art where there is some, and + /// its silhouette where there is not. Same rule as the page, checked on + /// the other side of the split because the two reach the bytes by + /// completely different routes - an `` address, and a fetch. + #[test] + fn the_card_paints_what_was_fetched() { + let mut doc = result(); + doc["players"][0]["units"] = serde_json::json!([damaged_griffin()]); + let summary = summarise(&doc).expect("a summary"); + let name = summary.forces[0].units[0]["name"] + .as_str() + .expect("the machine is named") + .to_owned(); + + let mut art = std::collections::HashMap::new(); + art.insert( + name.clone(), + std::sync::Arc::new(sprite_bytes("atlas").to_vec()), + ); + let camo: Vec>> = summary.forces.iter().map(|_| None).collect(); + let machine = |art: &std::collections::HashMap>>| { + compose( + &summary, + Drawn { + layout: card::Layout::Dossier, + brief: &Brief::default(), + eyebrow: "SCENARIO: FIRST RUN", + verdict: "VICTORY", + title: "@a.example won", + sub: "First Run", + perspective: None, + camo: &camo, + board: None, + art, + }, + ) + .forces[0] + .machines[0] + .sprite + .to_vec() + }; + assert_eq!( + machine(&art), + sprite_bytes("atlas"), + "the fetched art did not reach the card" + ); + assert_eq!( + machine(&no_art()), + sprite_bytes(sprite_for(&summary.forces[0].units[0])), + "an unfetched machine did not keep its silhouette" + ); + } + + /// No index at all - development, or a fetch that failed - is the page + /// exactly as it was, not a page of broken images. + #[test] + fn no_index_draws_the_silhouettes() { + let thunderbolt = serde_json::json!({ + "name": "Thunderbolt TDR-5S", "kind": "mek", "tons": 65.0, "status": "active" + }); + let tile = unit_tile("u0-0", &thunderbolt, None); + assert!(tile.contains("/reports/sprites/timberwolf.png"), "{tile}"); + } + fn result() -> serde_json::Value { serde_json::json!({ "round": 7, @@ -2742,6 +2983,7 @@ mod tests { // The force card comes first, and the sheet is behind a click that // needs no script. assert!(html.contains("class=\"utile"), "the force card tile"); + // No index in this test, so every machine falls back to its class. assert!(html.contains("/reports/sprites/"), "the silhouette"); assert!( html.contains("class=\"modal\" id=\"u0-0\""), @@ -2900,6 +3142,7 @@ mod tests { perspective: None, camo: &camo, board: None, + art: &no_art(), }, )) .expect("the card draws") @@ -3154,6 +3397,7 @@ mod tests { perspective: perspective.as_ref(), camo: &camo, board: ground.clone(), + art: &local_art(&summary), }, )) .expect("the card draws"); @@ -3293,6 +3537,7 @@ mod tests { perspective: None, camo: &camo, board, + art: &no_art(), }, )) .expect("the card draws") @@ -3375,6 +3620,7 @@ mod tests { offered: &offered, chosen: offered[0], participant: true, + units: None, }); for layout in &offered { let query = format!("?perspective=a.example&card={}", layout.slug()); @@ -3437,6 +3683,7 @@ mod tests { offered: &offered, chosen: offered[0], participant, + units: None, }) }; assert!(menu(true).contains("Post report to Bluesky")); @@ -3520,6 +3767,7 @@ mod tests { perspective: None, camo: &camo, board: None, + art: &no_art(), }, )) .expect("the card draws") @@ -3566,6 +3814,7 @@ mod tests { offered: &only, chosen: card::Layout::Dossier, participant: true, + units: None, }); let _ = (&summary, &context); assert!(!html.contains("class=\"turn"), "arrows with one card"); diff --git a/services/api/src/units.rs b/services/api/src/units.rs new file mode 100644 index 0000000..65309c6 --- /dev/null +++ b/services/api/src/units.rs @@ -0,0 +1,296 @@ +//! 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 every entry carries +//! the sprite it resolved along with the release the whole file describes. +//! 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, + /// 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, + sprite_base: String, + units: Vec, +} + +#[derive(serde::Deserialize)] +struct Entry { + name: String, + sprite: Option, +} + +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 response = match http.get(url).send().await { + Ok(response) => response, + Err(e) => { + tracing::warn!("unit index: fetch failed: {e}"); + return None; + } + }; + if !response.status().is_success() { + tracing::warn!(status = %response.status(), "unit index: refused"); + return None; + } + let body = match response.bytes().await { + Ok(body) => body, + Err(e) => { + tracing::warn!("unit index: read failed: {e}"); + return None; + } + }; + let document: Document = match serde_json::from_slice(&body) { + Ok(document) => document, + Err(e) => { + tracing::warn!("unit index: not the index format: {e}"); + return None; + } + }; + let by_name: HashMap = document + .units + .into_iter() + .filter_map(|unit| Some((unit.name, unit.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, + sprite_base: document.sprite_base, + by_name, + origin: origin.to_owned(), + http, + art: Mutex::new(HashMap::new()), + }) + } + + /// The address of a design's picture, or `None` for one the index does + /// not carry. + /// + /// 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 art_url(&self, name: &str) -> Option { + let sprite = self.by_name.get(name)?; + 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()) + } + + /// A design's picture as bytes, for the drawn card. `None` for a design + /// the index does not carry and for art that would not come down, in + /// both of which cases the caller draws its silhouette instead. + pub async fn art(&self, name: &str) -> Option>> { + let sprite = self.by_name.get(name)?.clone(); + if let Some(found) = self + .art + .lock() + .expect("art cache is not poisoned") + .get(&sprite) + { + return Some(Arc::clone(found)); + } + let url = self.art_url(name)?; + // 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) + } +} + +/// 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(), + 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.art_url("Atlas AS7-D").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.art_url("Battle Hawk BH-K305").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.art_url("Demolisher Heavy Tank").is_none()); + } + + /// 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")); + } +}