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