diff --git a/services/api/src/card.rs b/services/api/src/card.rs index ce24ae0..47d122b 100644 --- a/services/api/src/card.rs +++ b/services/api/src/card.rs @@ -142,6 +142,9 @@ pub enum Layout { Duel, /// The day's challenge, stamped with its date. Daily, + /// The board as it ended, as the whole picture. Only for a match that + /// uploaded one. + Battlefield, } /// Everything the card says. @@ -173,6 +176,7 @@ impl Layout { Layout::Lance => "lance", Layout::Duel => "duel", Layout::Daily => "daily", + Layout::Battlefield => "battlefield", } } @@ -182,6 +186,7 @@ impl Layout { "lance" => Layout::Lance, "duel" => Layout::Duel, "daily" => Layout::Daily, + "battlefield" => Layout::Battlefield, _ => return None, }) } @@ -193,6 +198,7 @@ impl Layout { Layout::Lance => "My lance", Layout::Duel => "Head to head", Layout::Daily => "The challenge", + Layout::Battlefield => "The battlefield", } } } @@ -587,17 +593,17 @@ struct Kit<'a> { text: FontRef<'a>, } -/// The ground every card stands on: the board, well back, under a wash that -/// keeps the type legible whatever the battlefield looks like. -fn draw_ground(canvas: &mut Canvas, card: &Card, dim: f32, wash_from: f32) { - // The hex ground first, tiled edge to edge. It carries a card that has no - // board behind it and gives one that does a floor to sit on. +/// The ground every card stands on: the hex tile, under a wash that keeps the +/// type legible. +/// +/// The board render used to be laid over this, and the two together read as +/// noise - a drawn map showing through a photographed one. The board is a +/// card of its own now (`Layout::Battlefield`), where it is the picture +/// rather than a texture behind one. +fn draw_ground(canvas: &mut Canvas, _card: &Card, _dim: f32, wash_from: f32) { if let Some(hex) = decode(HEX) { tile(canvas, &hex, 0.55); } - if let Some(board) = card.board.as_ref().and_then(|bytes| decode(bytes)) { - draw_backdrop(canvas, &board, dim); - } for y in 0..HEIGHT as i32 { let depth = (y as f32 / HEIGHT as f32 - wash_from).max(0.0) / (1.0 - wash_from); canvas.rect(0, y, WIDTH as i32, 1, BACKDROP, depth * 0.80); @@ -739,10 +745,54 @@ pub fn draw(card: &Card) -> Option> { Layout::Lance => draw_lance(&mut canvas, &kit, card), Layout::Duel => draw_duel(&mut canvas, &kit, card), Layout::Daily => draw_daily(&mut canvas, &kit, card), + Layout::Battlefield => draw_battlefield(&mut canvas, &kit, card), } canvas.into_png() } +/// The board as the picture, rather than as a texture behind one. +/// +/// Full bleed and barely dimmed, with the type sitting on scrims rather than +/// on a wash over the whole card: the point of this one is the terrain the +/// match was actually fought over, and every pixel spent making it quieter is +/// spent against it. Falls back to the ordinary ground if the board will not +/// decode, so this never renders as an empty card. +fn draw_battlefield(canvas: &mut Canvas, kit: &Kit, card: &Card) { + match card.board.as_ref().and_then(|bytes| decode(bytes)) { + Some(board) => { + draw_backdrop(canvas, &board, 0.12); + // Scrims top and bottom, so the type has ground without the whole + // picture being dimmed for it. + for y in 0..HEIGHT as i32 { + let from_top = 1.0 - (y as f32 / 260.0).min(1.0); + let from_bottom = 1.0 - ((HEIGHT as i32 - y) as f32 / 200.0).min(1.0); + let shade = from_top.max(from_bottom); + if shade > 0.0 { + canvas.rect(0, y, WIDTH as i32, 1, BACKDROP, shade * 0.86); + } + } + canvas.rect(0, 0, WIDTH as i32, 4, BRAND, 1.0); + } + None => draw_ground(canvas, card, 0.74, 0.26), + } + + let y = draw_heading(canvas, kit, card); + let y = draw_verdict(canvas, kit, card, y); + if !card.title.is_empty() { + let room = (WIDTH as i32 - MARGIN * 2) as f32; + let (title, size) = clipped(&kit.display, card.title, 32.0, 20.0, 0.0, room); + draw_text( + canvas, + &kit.display, + &title, + MARGIN, + y + 40, + ink(size, INK, 0.0), + ); + } + draw_footer(canvas, kit, card); +} + /// Everyone who fought, allies together. /// /// Two shapes, because a 1v1 and a four-cornered fight are not the same diff --git a/services/api/src/matches/aws.rs b/services/api/src/matches/aws.rs index 335e163..bf8016a 100644 --- a/services/api/src/matches/aws.rs +++ b/services/api/src/matches/aws.rs @@ -155,6 +155,36 @@ impl Aws { /// The only read of the artifacts bucket. Everything else here signs a /// URL and lets the container do the transfer; a finished match's result /// has to be read by us, because it is what a score is computed from. + /// Whether an artifact is there, without pulling it. The page needs to + /// know a board exists to offer the card drawn on it, and a board render + /// is megabytes. + pub async fn has_artifact( + &self, + bucket: &str, + match_id: &str, + name: &str, + ) -> Result { + match self + .s3 + .head_object() + .bucket(bucket) + .key(format!("matches/{match_id}/artifacts/{name}")) + .send() + .await + { + Ok(_) => Ok(true), + Err(e) => { + // A head on a missing key answers 404 with no modelled error + // variant, unlike get_object's NoSuchKey. + if e.raw_response().is_some_and(|r| r.status().as_u16() == 404) { + Ok(false) + } else { + Err(AwsFailed("artifact head")) + } + } + } + } + pub async fn get_artifact( &self, bucket: &str, diff --git a/services/api/src/matches/mod.rs b/services/api/src/matches/mod.rs index 2efc45d..ef1c869 100644 --- a/services/api/src/matches/mod.rs +++ b/services/api/src/matches/mod.rs @@ -190,6 +190,23 @@ impl Matches { found } + /// Whether the match left this artifact behind. `false` covers both "no + /// such file" and "could not tell"; the caller shortens its caching + /// either way, because a board can still arrive from the exit pass. + pub async fn has_artifact(&self, match_id: &str, name: &str) -> bool { + match self + .aws + .has_artifact(&self.config.artifacts_bucket, match_id, name) + .await + { + Ok(found) => found, + Err(_) => { + tracing::warn!(match_id, name, "artifact head failed"); + false + } + } + } + pub async fn artifact(&self, match_id: &str, name: &str) -> Option> { self.aws .get_artifact(&self.config.artifacts_bucket, match_id, name) diff --git a/services/api/src/share.rs b/services/api/src/share.rs index c88ba84..5cdc558 100644 --- a/services/api/src/share.rs +++ b/services/api/src/share.rs @@ -1589,7 +1589,10 @@ pub async fn page( }; let perspective = Perspective::read(ask.perspective.as_deref()); - let offered = layouts(&context, &summary, perspective.is_some()); + // Asked rather than fetched: the board is megabytes and this only needs + // to know whether the card drawn on it can be offered. + let board = matches.has_artifact(&id, CARD_ARTIFACT).await; + let offered = layouts(&context, &summary, perspective.is_some(), board); let chosen = ask .card .as_deref() @@ -1623,7 +1626,18 @@ pub async fn page( // A result never changes once it exists, so this is worth caching - // and the caching is what keeps an unfurler, a feed and everyone who // taps the post off the artifacts bucket. - [(header::CACHE_CONTROL, "public, max-age=3600")], + // + // Short while the board is missing, for the same reason the card is: + // it can still arrive from the container's exit pass, and it decides + // whether this page offers the card drawn on it. + [( + header::CACHE_CONTROL, + if board { + "public, max-age=3600" + } else { + "public, max-age=60" + }, + )], Html(document( &summary, &context, @@ -1829,7 +1843,7 @@ pub async fn card( } } - let offered = layouts(&context, &summary, perspective.is_some()); + let offered = layouts(&context, &summary, perspective.is_some(), board.is_some()); let layout = ask .card .as_deref() @@ -1916,7 +1930,18 @@ impl Ask { /// Which card a match should offer, given how it was reached and who was in /// it. The share menu lists these; the first is the one a bare link gets. -fn layouts(context: &Context, summary: &Summary, perspective: bool) -> Vec { +/// The cards this match can offer, best first. +/// +/// Only what it can actually draw: a duel needs two forces, a lance needs +/// somebody's own, the challenge card needs a challenge, and the battlefield +/// needs a board render. Nothing is listed that would come back as a worse +/// version of the dossier. +fn layouts( + context: &Context, + summary: &Summary, + perspective: bool, + board: bool, +) -> Vec { let mut out = Vec::new(); if matches!(context.entry, Entry::Daily { .. }) { out.push(card::Layout::Daily); @@ -1929,6 +1954,9 @@ fn layouts(context: &Context, summary: &Summary, perspective: bool) -> Vec String { let context = fixture_context(); - let offered = layouts(&context, summary, false); + let offered = layouts(&context, summary, false, false); document( summary, &context, @@ -2841,6 +2869,7 @@ mod tests { card::Layout::Lance, card::Layout::Duel, card::Layout::Daily, + card::Layout::Battlefield, ] { // A duel card only means anything with two forces in it. if layout == card::Layout::Duel && summary.forces.len() != 2 { @@ -2852,6 +2881,11 @@ mod tests { let sample = std::env::var("CARD_CAMO") .ok() .and_then(|path| std::fs::read(path).ok()); + // And a board when CARD_BOARD names one, which is the only + // way to look at the battlefield card. + let ground = std::env::var("CARD_BOARD") + .ok() + .and_then(|path| std::fs::read(path).ok()); let camo: Vec>> = summary .forces .iter() @@ -2879,7 +2913,7 @@ mod tests { sub: &sub, perspective: perspective.as_ref(), camo: &camo, - board: None, + board: ground.clone(), }, )) .expect("the card draws"); @@ -2994,6 +3028,55 @@ mod tests { } } + /// The board is the battlefield card's picture and nothing else's. Every + /// other layout draws the same bytes whether a board exists or not - the + /// two used to be stacked, and a drawn map showing through a rendered one + /// is noise. + #[test] + fn only_the_battlefield_card_uses_the_board() { + let doc = result(); + let summary = summarise(&doc).expect("a summary"); + let context = fixture_context(); + let brief = context.brief(); + let ground = std::fs::read("assets/art/battlespace-hex.png").expect("a stand-in board"); + let camo: Vec>> = summary.forces.iter().map(|_| None).collect(); + let draw = |layout, board: Option>| { + card::draw(&compose( + &summary, + Drawn { + layout, + brief: &brief, + eyebrow: "SCENARIO: FIRST RUN", + verdict: "", + title: "a match", + sub: "", + perspective: None, + camo: &camo, + board, + }, + )) + .expect("the card draws") + }; + for layout in [ + card::Layout::Dossier, + card::Layout::Duel, + card::Layout::Daily, + ] { + assert_eq!( + draw(layout, None), + draw(layout, Some(ground.clone())), + "{} changed when a board was supplied", + layout.slug() + ); + } + // And the battlefield card is the one that does change. + assert_ne!( + draw(card::Layout::Battlefield, None), + draw(card::Layout::Battlefield, Some(ground.clone())), + "the battlefield card ignored the board" + ); + } + /// The row's status is what separates "not yet" from "not ever". #[test] fn a_live_match_is_unfinished_and_a_dead_one_is_not() {