diff --git a/Cargo.lock b/Cargo.lock index fb379ca..16aaf67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1539,6 +1539,7 @@ dependencies = [ "hyper 1.11.0", "hyper-util", "jose-jwk", + "jpeg-decoder", "png", "rand 0.8.7", "reqwest", @@ -1997,6 +1998,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + [[package]] name = "js-sys" version = "0.3.103" diff --git a/Cargo.toml b/Cargo.toml index ad13334..ec9b782 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,11 @@ cookie = "0.18" # Decoding and re-encoding a camo PNG. Pure Rust, no system library. png = "0.18" +# The other half of decoding an avatar. A PDS hands back whatever the account +# uploaded, and what a Bluesky client uploads is JPEG, so a card drawn from +# PNG alone would have a hole in it for most accounts. image-rs's decoder, +# pure Rust like the rest of this, and decode-only. +jpeg-decoder = { version = "0.3", default-features = false } # Glyph outlines for the social card the API draws. Pure Rust, no system # fonts and no C: the faces it rasterises are the two in # services/api/assets/fonts, vendored beside their licences. diff --git a/services/api/Cargo.toml b/services/api/Cargo.toml index f5d651f..d311e99 100644 --- a/services/api/Cargo.toml +++ b/services/api/Cargo.toml @@ -44,6 +44,7 @@ axum-extra.workspace = true cookie.workspace = true png.workspace = true +jpeg-decoder.workspace = true ab_glyph.workspace = true hmac.workspace = true sha2.workspace = true diff --git a/services/api/src/atproto/mod.rs b/services/api/src/atproto/mod.rs index 519d11d..c76b98f 100644 --- a/services/api/src/atproto/mod.rs +++ b/services/api/src/atproto/mod.rs @@ -411,6 +411,68 @@ impl Atproto { Some(listing.get("records")?.as_array()?.len()) } + /// The account's own picture, as it uploaded it. + /// + /// From the PDS rather than from the appview's CDN: the appview only + /// knows accounts it has indexed, and this has to answer for an account + /// that has never posted. The bytes are whatever was uploaded, so the + /// card decodes both formats it can be (see card.rs). + /// + /// None where there is no picture, where the repository could not be + /// read, or where the blob is larger than a portrait is worth: an avatar + /// is a small square by the time anybody sees it, and a card must not + /// wait on somebody's ten-megabyte upload. + pub async fn avatar(&self, did: &str) -> Option> { + const MOST: usize = 4 * 1024 * 1024; + + let did = Did::new(did.to_owned()).ok()?; + let document = self.did_resolver.resolve(&did).await.ok()?; + let pds = document.get_pds_endpoint()?; + let record: serde_json::Value = self + .fetch_json(&format!( + "{pds}/xrpc/com.atproto.repo.getRecord\ + ?repo={did}&collection=app.bsky.actor.profile&rkey=self", + did = did.as_str() + )) + .await + .ok()?; + + // Both spellings, for the reason published_camo reads both: `ref.$link` + // is the current blob shape and a bare `cid` is what the format looked + // like before it settled, and both are in live repositories. + let cid = record + .pointer("/value/avatar/ref/$link") + .or_else(|| record.pointer("/value/avatar/cid")) + .and_then(serde_json::Value::as_str)?; + if !is_blob_cid(cid) { + tracing::warn!(did = did.as_str(), "avatar: reference is not a CID"); + return None; + } + + let response = self + .get(&format!( + "{pds}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}", + did = did.as_str() + )) + .await + .ok()?; + if !response.status().is_success() { + return None; + } + // Refused on the declared length where there is one, and on the body + // otherwise: a length header is a claim, and the check that matters + // is on what actually arrived. + if response.content_length().is_some_and(|n| n as usize > MOST) { + tracing::info!(did = did.as_str(), "avatar: larger than a portrait needs"); + return None; + } + let bytes = response.bytes().await.ok()?; + if bytes.len() > MOST { + return None; + } + Some(bytes.to_vec()) + } + /// Best-effort server-side revocation. The stored session row is deleted /// by atrium on success; the caller deletes it unconditionally as /// belt-and-braces either way. diff --git a/services/api/src/card.rs b/services/api/src/card.rs index 938a8c8..e8b9cf5 100644 --- a/services/api/src/card.rs +++ b/services/api/src/card.rs @@ -269,7 +269,56 @@ struct Picture { rgba: Vec, } +/// A picture, from whichever of the two formats it is in. +/// +/// PNG is what everything this module draws with is: MegaMek's art, the hex +/// tile, a camo, a board render. An avatar is the exception - it is whatever +/// the account uploaded, and what a Bluesky client uploads is JPEG - so the +/// two are tried in turn rather than one being assumed. fn decode(bytes: &[u8]) -> Option { + decode_png(bytes).or_else(|| decode_jpeg(bytes)) +} + +/// A JPEG, expanded to the same straight RGBA everything here draws from. +/// +/// Greyscale and RGB are the two shapes a photograph arrives in. CMYK is +/// refused rather than converted: it needs an ICC profile to mean anything, +/// and an avatar in it is rare enough that a card without a picture is the +/// better answer than one with the colours inverted. +fn decode_jpeg(bytes: &[u8]) -> Option { + let mut decoder = jpeg_decoder::Decoder::new(std::io::Cursor::new(bytes)); + let pixels = decoder.decode().ok()?; + let info = decoder.info()?; + let (width, height) = (info.width as u32, info.height as u32); + let count = (width as usize).checked_mul(height as usize)?; + let mut rgba = Vec::with_capacity(count.checked_mul(4)?); + match info.pixel_format { + jpeg_decoder::PixelFormat::L8 => { + if pixels.len() < count { + return None; + } + for &grey in &pixels[..count] { + rgba.extend_from_slice(&[grey, grey, grey, 255]); + } + } + jpeg_decoder::PixelFormat::RGB24 => { + if pixels.len() < count * 3 { + return None; + } + for pixel in pixels[..count * 3].chunks_exact(3) { + rgba.extend_from_slice(&[pixel[0], pixel[1], pixel[2], 255]); + } + } + _ => return None, + } + Some(Picture { + width, + height, + rgba, + }) +} + +fn decode_png(bytes: &[u8]) -> Option { let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes)); decoder.set_limits(png::Limits { // A board render is the largest thing here and is a few megapixels; @@ -302,6 +351,115 @@ fn decode(bytes: &[u8]) -> Option { }) } +/// A photograph, drawn square and cropped to fill. +/// +/// Averaged rather than sampled, unlike `draw_sprite`: a sprite is pixel art +/// and smoothing it turns MegaMek's hexes into mush, and an avatar is a +/// photograph coming down from 1000-odd pixels to two hundred, where taking +/// one pixel in five is what makes a face look like a mosaic. +/// +/// `radius` rounds the corners. The plate the rest of the card is drawn on is +/// notched rather than rounded, so this is a small radius rather than a +/// circle: a circular portrait beside notched panels reads as two designs. +fn draw_portrait(canvas: &mut Canvas, picture: &Picture, x: i32, y: i32, size: i32, radius: f32) { + if picture.width == 0 || picture.height == 0 || size <= 0 { + return; + } + // Cover: the short side decides the scale, and the long one is cropped + // evenly. A face is usually in the middle of the frame. + let side = picture.width.min(picture.height) as f32; + let left = (picture.width as f32 - side) / 2.0; + let top = (picture.height as f32 - side) / 2.0; + let step = side / size as f32; + + for row in 0..size { + for column in 0..size { + // The box this destination pixel covers in the source. + let sx0 = left + column as f32 * step; + let sy0 = top + row as f32 * step; + let (mut r, mut g, mut b, mut a, mut n) = (0.0, 0.0, 0.0, 0.0, 0.0); + let mut sy = sy0; + while sy < sy0 + step { + let mut sx = sx0; + while sx < sx0 + step { + let px = (sx as u32).min(picture.width - 1); + let py = (sy as u32).min(picture.height - 1); + let index = ((py * picture.width + px) * 4) as usize; + r += picture.rgba[index] as f32; + g += picture.rgba[index + 1] as f32; + b += picture.rgba[index + 2] as f32; + a += picture.rgba[index + 3] as f32; + n += 1.0; + sx += 1.0; + } + sy += 1.0; + } + if n == 0.0 { + continue; + } + let coverage = (a / n / 255.0) * corner_coverage(column, row, size, radius); + canvas.blend( + x + column, + y + row, + [(r / n) as u8, (g / n) as u8, (b / n) as u8], + coverage, + ); + } + } +} + +/// How much of a pixel is inside a rounded square. 1 away from the corners, +/// and a soft edge across each corner's arc so the rounding is not a staircase. +fn corner_coverage(column: i32, row: i32, size: i32, radius: f32) -> f32 { + if radius <= 0.0 { + return 1.0; + } + let (x, y) = (column as f32 + 0.5, row as f32 + 0.5); + let far = size as f32 - radius; + let cx = if x < radius { + radius + } else if x > far { + far + } else { + return 1.0; + }; + let cy = if y < radius { + radius + } else if y > far { + far + } else { + return 1.0; + }; + let distance = ((x - cx).powi(2) + (y - cy).powi(2)).sqrt(); + (radius + 0.5 - distance).clamp(0.0, 1.0) +} + +/// The letter an account stands behind when it has no picture, or none this +/// module could decode. +/// +/// The same monogram the site draws in the same place, for the same reason: +/// an empty square reads as a picture that failed to load, and a letter reads +/// as an account. +fn draw_monogram(canvas: &mut Canvas, kit: &Kit, name: &str, x: i32, y: i32, size: i32) { + canvas.rect(x, y, size, size, PANEL, 0.9); + let letter = name + .chars() + .find(|c| c.is_ascii_alphanumeric()) + .unwrap_or('?') + .to_ascii_uppercase() + .to_string(); + let scale = size as f32 * 0.52; + let width = measure(&kit.display, &letter, scale, 0.0); + draw_text( + canvas, + &kit.display, + &letter, + x + (size - width as i32) / 2, + y + size - (size as f32 * 0.28) as i32, + ink(scale, MUTED, 0.0), + ); +} + /// Draw a picture over the whole canvas, cropped to fill it, dimmed. /// /// Cover rather than fit: a letterboxed board leaves bars the card cannot use @@ -732,19 +890,36 @@ fn draw_verdict(canvas: &mut Canvas, kit: &Kit, card: &Card, y: i32) -> i32 { /// `None` only if the fonts do not parse or the PNG will not encode, neither /// of which depends on the match: the caller falls back to the board itself. /// What a player's card says. -/// -/// No picture of them: an avatar is whatever the account uploaded, and this -/// module decodes PNG only (see `decode`), so half of them would silently be -/// a card with a hole in it. Type on the site's own ground is a card that is -/// always drawn. pub struct Player<'a> { /// "@handle", or the DID where no handle resolves back to it. + /// + /// The DID used to be printed under this and is not any more: it is how a + /// bug report names an account, not how a person does, and the line it + /// took is worth more to the bio. pub name: &'a str, - /// Always the DID, under the name. It is the account, and it is stable. - pub did: &'a str, - /// Label and figure, in the order the page shows them. A figure nothing - /// records yet is an em dash here exactly as it is on the page. - pub stats: Vec<(String, String)>, + /// The account's own picture as it uploaded it, in whichever format that + /// is. None where it has none, or where the repository would not answer: + /// the card draws a monogram in its place rather than a hole. + pub avatar: Option>, + /// The figures that are a standing, in the order they are read. + /// + /// Separate from `activity` because they are a different kind of claim: + /// a rating and a kill ratio say how somebody plays and want to be read + /// against each other, which is what an instrument column is for. A + /// figure nothing records yet is an em dash here exactly as it is on the + /// page. + pub scores: Vec<(String, String)>, + /// The figures that are a count of what somebody has done: matches + /// played, forces built, camo painted. Blocks rather than instruments - + /// they are read one at a time and none of them means anything against + /// the others. + pub activity: Vec<(String, String)>, + /// A line about the player, when there is somewhere for one to come from. + pub bio: Option, + /// How many medals to draw. Nothing awards one yet. + pub medals: usize, + /// Whether the faction mark is drawn behind the card. + pub faction: bool, } /// A player's card, for the link somebody posts about them. @@ -754,14 +929,17 @@ pub fn draw_player(player: &Player) -> Option> { text: FontRef::try_from_slice(TEXT).ok()?, }; let mut canvas = Canvas::new(WIDTH, HEIGHT, BACKDROP); - draw_ground(&mut canvas, 0.16); + let portrait = player.avatar.as_deref().and_then(decode); + draw_hud_player(&mut canvas, &kit, player, portrait.as_ref()); + canvas.into_png() +} - // The same header every other card wears, minus the match it was about: - // a label in the brand colour, a rule, and the mark on the right. +/// The header every one of them wears: the label, a rule, and the mark. +fn draw_player_header(canvas: &mut Canvas, kit: &Kit) -> i32 { let y = MARGIN + 30; let end = draw_text( - &mut canvas, - &kit.display, + canvas, + kit_display(kit), "PLAYER", MARGIN, y, @@ -771,84 +949,350 @@ pub fn draw_player(player: &Player) -> Option> { if mark > end + 18 { canvas.rect(end + 18, y - 8, mark - end - 18, 1, LINE, 0.9); } - draw_mark(&mut canvas, &kit, WIDTH as i32 - MARGIN, y); + draw_mark(canvas, kit, WIDTH as i32 - MARGIN, y); + y +} - // The name, as large as it fits. A handle is a domain and can be long, - // so it is fitted rather than clipped where there is room for it. - let room = (WIDTH as i32 - MARGIN * 2) as f32; - let (name, size) = clipped(&kit.display, player.name, 84.0, 40.0, 0.0, room); - let baseline = y + 150; +fn kit_display<'a>(kit: &'a Kit) -> &'a FontRef<'a> { + &kit.display +} + +/// The picture, or the letter it stands in for. +fn draw_face( + canvas: &mut Canvas, + kit: &Kit, + player: &Player, + portrait: Option<&Picture>, + x: i32, + y: i32, + size: i32, +) { + match portrait { + Some(picture) => draw_portrait(canvas, picture, x, y, size, 10.0), + None => draw_monogram(canvas, kit, player.name, x, y, size), + } + // A hairline around it, so a dark photograph does not dissolve into the + // ground it is standing on. + canvas.rect(x, y, size, 1, LINE, 0.9); + canvas.rect(x, y + size - 1, size, 1, LINE, 0.9); + canvas.rect(x, y, 1, size, LINE, 0.9); + canvas.rect(x + size - 1, y, 1, size, LINE, 0.9); +} + +/// The name, as large as the column allows. +/// +/// Just the name: the DID used to be set under it, and it is debugging +/// information rather than something anybody reads. What it was taking is +/// where the bio goes. +fn draw_who( + kit: &Kit, + canvas: &mut Canvas, + player: &Player, + x: i32, + baseline: i32, + room: f32, + big: f32, +) { + let (name, size) = clipped(&kit.display, player.name, big, 34.0, 0.0, room); draw_text( - &mut canvas, + canvas, &kit.display, &name, - MARGIN, + x, baseline, ink(size, INK, 0.0), ); +} - // The DID under it, the way the page prints it: the handle is rented and - // this is not. Not where it *is* the name - an account with no handle - // anybody can confirm would otherwise carry the same string twice. - if player.name != player.did { - let (did, did_size) = clipped(&kit.text, player.did, 26.0, 16.0, 1.0, room); - draw_text( - &mut canvas, - &kit.text, - &did, - MARGIN, - baseline + 44, - ink(did_size, MUTED, 1.0), - ); - } +/// One figure with no panel under it: for the shapes that rule instead. +fn stat_bare( + canvas: &mut Canvas, + kit: &Kit, + x: i32, + baseline: i32, + stat: &(String, String), + figure_size: f32, +) { + draw_text( + canvas, + &kit.display, + &stat.1, + x, + baseline, + ink(figure_size, INK, 0.0), + ); + let upper = stat.0.to_uppercase(); + draw_text( + canvas, + &kit.display, + &upper, + x, + baseline + 26, + ink(16.0, MUTED, 3.0), + ); +} - draw_player_stats(&mut canvas, &kit, player, baseline + 130); - canvas.into_png() +/// Where everything on a player's card sits. +/// +/// Two columns, because the card is two things: on the right the account - +/// its picture, its medals, the mark it flies under - and on the left what it +/// has done. Fixed rather than flowed, so that a shape which leaves a region +/// empty leaves it empty in the same place as the shape that fills it, and +/// the five can be compared without measuring. +mod player_grid { + use super::{HEIGHT, MARGIN, WIDTH}; + + /// The account's column, on the right. + pub const FACE: i32 = 280; + pub const FACE_X: i32 = WIDTH as i32 - MARGIN - FACE; + pub const FACE_Y: i32 = 140; + /// Under the picture: the medals, and then the mark. + pub const MEDALS_Y: i32 = FACE_Y + FACE + 26; + pub const MEDAL: i32 = 44; + + /// What it has done, on the left. + pub const LEFT: i32 = MARGIN; + pub const RIGHT: i32 = FACE_X - 50; + pub const ROOM: i32 = RIGHT - LEFT; + pub const NAME_BASE: i32 = 196; + /// The line or three about the player, in the room the DID used to take. + pub const BIO_Y: i32 = 262; + /// The standing, as instruments. + pub const SCORES_Y: i32 = 384; + /// The counts, ruled along the foot. + pub const COUNTS_Y: i32 = HEIGHT as i32 - MARGIN - 96; } -/// The figures, as a row of panels along the bottom of the card. -fn draw_player_stats(canvas: &mut Canvas, kit: &Kit, player: &Player, top: i32) { - if player.stats.is_empty() { - return; +/// Corner ticks around the portrait, the way the ground marks its own. +fn draw_ticks(canvas: &mut Canvas, x: i32, y: i32, size: i32) { + let tick = 30; + for (cx, cy, dx, dy) in [ + (x - 14, y - 14, 1, 1), + (x + size + 14, y - 14, -1, 1), + (x - 14, y + size + 14, 1, -1), + (x + size + 14, y + size + 14, -1, -1), + ] { + let (ax, ay) = (cx.min(cx + dx * tick), cy.min(cy + dy * tick)); + canvas.rect(ax, cy, tick, 2, BRAND, 0.75); + canvas.rect(cx.min(cx + dx * 2), ay, 2, tick, BRAND, 0.75); } - let count = player.stats.len() as i32; - let room = WIDTH as i32 - MARGIN * 2; - let gap = 18; - // Capped, not divided: one figure stretched across the whole card reads - // as an empty table rather than as a number, and the row is meant to - // grow leftwards as there is more to say about a player. - let width = ((room - gap * (count - 1)) / count).min(260); - let height = 150; - - for (index, (label, value)) in player.stats.iter().enumerate() { - let x = MARGIN + index as i32 * (width + gap); - canvas.rect(x, top, width, height, PANEL, 0.72); - canvas.rect(x, top, width, 2, LINE, 0.9); - - let (figure, size) = clipped(&kit.display, value, 56.0, 28.0, 0.0, width as f32 - 32.0); +} + +/// A standing, read as an instrument: the word, a leader, the figure. +/// +/// The leader is what makes an em dash read as a gauge at rest rather than as +/// a number somebody forgot to fill in, which matters while neither of these +/// has a source yet. +fn draw_scores(canvas: &mut Canvas, kit: &Kit, player: &Player, top: i32) { + use player_grid::{LEFT, RIGHT}; + let mut y = top; + for stat in &player.scores { + let upper = stat.0.to_uppercase(); + let label_end = draw_text(canvas, &kit.display, &upper, LEFT, y, ink(20.0, MUTED, 3.0)); + let figure_width = measure(&kit.display, &stat.1, 38.0, 0.0) as i32; + let leader = RIGHT - figure_width - label_end - 28; + if leader > 0 { + canvas.rect(label_end + 14, y - 8, leader, 1, LINE, 0.6); + } draw_text( canvas, &kit.display, - &figure, - x + 20, - top + 78, - ink(size, INK, 0.0), + &stat.1, + RIGHT - figure_width, + y, + ink(38.0, INK, 0.0), ); + y += 60; + } +} + +/// The counts, ruled rather than panelled. +fn draw_activity_strip(canvas: &mut Canvas, kit: &Kit, player: &Player) { + use player_grid::{COUNTS_Y, LEFT, ROOM}; + let top = COUNTS_Y + 10; + canvas.rect(LEFT, top, ROOM, 1, LINE, 0.9); + let count = player.activity.len().max(1) as i32; + let step = ROOM / count; + for (index, stat) in player.activity.iter().enumerate() { + let x = LEFT + index as i32 * step; + if index > 0 { + canvas.rect(x - 22, top + 8, 1, 72, LINE, 0.7); + } + stat_bare(canvas, kit, x, top + 56, stat, 44.0); + } +} - let upper = label.to_uppercase(); - let (caption, caption_size) = - clipped(&kit.display, &upper, 20.0, 14.0, 3.0, width as f32 - 32.0); +/// A line about the player, wrapped to three. +/// +/// Three because that is what the DID's line and the space under it add up +/// to, and because the fourth is where somebody starts writing an essay - and +/// this is a card in a timeline. Nothing writes one yet. +fn draw_bio(canvas: &mut Canvas, kit: &Kit, text: &str, top: i32) { + use player_grid::{LEFT, ROOM}; + for (index, line) in wrapped(&kit.text, text, 24.0, ROOM as f32) + .into_iter() + .take(3) + .enumerate() + { draw_text( canvas, - &kit.display, - &caption, - x + 20, - top + height - 28, - ink(caption_size, MUTED, 3.0), + &kit.text, + &line, + LEFT, + top + index as i32 * 32, + ink(24.0, MUTED, 0.0), ); } } +/// Medals, drawn as the notched plates every other control on the site is cut +/// from rather than as circles: a round badge would be the only round thing +/// on the card. +/// +/// Nothing awards one yet. These are drawn to see whether the row belongs on +/// the card at all, and what it costs the shapes around it. +fn draw_medals(canvas: &mut Canvas, x: i32, y: i32, count: usize, size: i32) { + let gap = 12; + for index in 0..count { + let left = x + index as i32 * (size + gap); + canvas.rect(left, y, size, size, PANEL, 0.85); + // The plate's notch, top left and bottom right, cut by hand. + let notch = size / 5; + for step in 0..notch { + canvas.rect(left, y + step, notch - step, 1, BACKDROP, 0.9); + canvas.rect( + left + size - (notch - step), + y + size - 1 - step, + notch - step, + 1, + BACKDROP, + 0.9, + ); + } + // A rank pip, so the row does not read as a row of empty boxes. + let pip = size / 3; + canvas.rect( + left + (size - pip) / 2, + y + (size - pip) / 2, + pip, + pip, + if index == 0 { BRAND } else { FAINT }, + 0.9, + ); + canvas.rect(left, y + size - 2, size, 2, LINE, 0.9); + } +} + +/// A faction's mark, ghosted behind the card. +/// +/// A drawn stand-in rather than anybody's real insignia: which factions a +/// player can fly under, and where those marks come from, is a decision +/// nobody has made. Concentric hexes and a chevron, at the weight a watermark +/// has to sit at to stay behind the words. +fn draw_faction_mark(canvas: &mut Canvas, cx: i32, cy: i32, size: i32) { + let radius = size as f32 / 2.0; + for (scale, alpha) in [(1.0, 0.20), (0.74, 0.15), (0.48, 0.11)] { + let r = radius * scale; + for step in 0..6 { + let a0 = std::f32::consts::PI / 3.0 * step as f32; + let a1 = std::f32::consts::PI / 3.0 * (step + 1) as f32; + line( + canvas, + cx as f32 + r * a0.cos(), + cy as f32 + r * a0.sin(), + cx as f32 + r * a1.cos(), + cy as f32 + r * a1.sin(), + BRAND, + alpha, + ); + } + } + // The chevrons through the middle, which is what stops it reading as a + // target rather than as a mark. + let arm = radius * 0.5; + for offset in [-arm * 0.45, arm * 0.15] { + line( + canvas, + cx as f32 - arm, + cy as f32 + offset + arm * 0.5, + cx as f32, + cy as f32 + offset - arm * 0.4, + BRAND, + 0.17, + ); + line( + canvas, + cx as f32, + cy as f32 + offset - arm * 0.4, + cx as f32 + arm, + cy as f32 + offset + arm * 0.5, + BRAND, + 0.17, + ); + } +} + +/// A straight line, stepped along its longer axis. +fn line(canvas: &mut Canvas, x0: f32, y0: f32, x1: f32, y1: f32, colour: [u8; 3], alpha: f32) { + let steps = ((x1 - x0).abs().max((y1 - y0).abs()) * 1.5).ceil() as i32; + for step in 0..=steps.max(1) { + let t = step as f32 / steps.max(1) as f32; + let x = x0 + (x1 - x0) * t; + let y = y0 + (y1 - y0) * t; + canvas.blend(x as i32, y as i32, colour, alpha); + canvas.blend(x as i32 + 1, y as i32, colour, alpha * 0.6); + } +} + +/// A player's card: the account on the right, what it has done on the left. +/// +/// The split the five shapes share is the thing being decided. A rating and a +/// kill ratio are a standing - they are read against each other and against +/// other players - so they are instruments with leaders. Matches, forces and +/// camo are counts of what somebody has done, read one at a time, so they are +/// blocks. Putting all five in one row made the two kinds look like one kind. +fn draw_hud_player(canvas: &mut Canvas, kit: &Kit, player: &Player, portrait: Option<&Picture>) { + use player_grid::*; + + draw_ground(canvas, 0.16); + if player.faction { + // Behind everything, and centred on the card rather than on either + // column: a watermark that sits inside one of them is an illustration. + draw_faction_mark(canvas, WIDTH as i32 / 2 + 60, HEIGHT as i32 / 2 + 20, 440); + } + draw_player_header(canvas, kit); + + draw_face(canvas, kit, player, portrait, FACE_X, FACE_Y, FACE); + draw_ticks(canvas, FACE_X, FACE_Y, FACE); + draw_who(kit, canvas, player, LEFT, NAME_BASE, ROOM as f32, 64.0); + + // A line or three about the player, where the DID used to be. Nothing + // writes one yet, and the room is held rather than closed up: what fills + // it is a decision about profiles, not about this card. + if let Some(bio) = &player.bio { + draw_bio(canvas, kit, bio, BIO_Y); + } + + // Where the instruments start. The room above them is the bio's, and + // nothing writes a bio yet: held open on a card that has none reads as a + // hole rather than as a reservation, so they come up to meet the name and + // drop back down the day there is a line to sit above them. + let scores_y = if player.bio.is_some() { + SCORES_Y + } else { + SCORES_Y - 56 + }; + draw_scores(canvas, kit, player, scores_y); + + // Medals under the picture, where they belong to the account rather than + // to the figures. Nothing awards one yet either. + if player.medals > 0 { + draw_medals(canvas, FACE_X, MEDALS_Y, player.medals, MEDAL); + } + + draw_activity_strip(canvas, kit, player); +} + pub fn draw(card: &Card) -> Option> { let kit = Kit { display: FontRef::try_from_slice(DISPLAY).ok()?, diff --git a/services/api/src/db.rs b/services/api/src/db.rs index 77cd86a..7ffbb4d 100644 --- a/services/api/src/db.rs +++ b/services/api/src/db.rs @@ -966,6 +966,27 @@ impl Db { .await } + /// How many matches this account has been in, by our own record of them. + /// + /// The same predicate `list_matches` uses, counted rather than listed: + /// the figure a player's card carries does not need the rows. Lobbies are + /// excluded there and here — a lobby somebody opened and left is not a + /// match anybody played. + pub async fn match_count(&self, did: &str) -> Result { + let did = did.to_owned(); + self.call(move |conn| { + conn.query_row( + "SELECT COUNT(*) FROM matches + WHERE status != 'lobby' + AND (owner_did = ?1 + OR id IN (SELECT match_id FROM match_players WHERE did = ?1))", + [did], + |row| row.get(0), + ) + }) + .await + } + pub async fn delete_oauth_session(&self, did: &str) -> Result<(), DbError> { let did = did.to_owned(); self.call(move |conn| { diff --git a/services/api/src/unfurl.rs b/services/api/src/unfurl.rs index 1b2adae..3bfde4a 100644 --- a/services/api/src/unfurl.rs +++ b/services/api/src/unfurl.rs @@ -26,10 +26,17 @@ use crate::share::escape; /// How long a preview is allowed to stand. /// -/// An hour, where a match report gets a day: a match is finished and cannot -/// change, and an account can be renamed, can publish another camo, or can -/// disappear. Nothing here is worth a stale card for longer than that. -const CACHING: &str = "public, max-age=3600"; +/// A minute, where a match report gets a day. A report is about something +/// that finished and cannot change; this is about an account as it is right +/// now - a match count that moves with every game, a pattern count that moves +/// whenever somebody paints one, a handle that can change hands. A card +/// cached for an hour is an hour of previews that disagree with the page they +/// point at. +/// +/// It bounds our own edge and nothing else: a card service snapshots what it +/// fetched when the post was made, and no header of ours reaches back into +/// somebody else's timeline to update it. +const CACHING: &str = "public, max-age=60"; /// The line under the name on the card and in the preview. /// @@ -43,11 +50,72 @@ struct Player { /// "@handle", or the DID where no handle resolves back to it. name: String, did: String, + /// The account's own picture, as it uploaded it. None where it has none. + avatar: Option>, /// Camo published, or None where the repository could not be read. A /// figure that could not be read is drawn as an em dash rather than as a /// zero: "none" and "we could not tell" are different things to say /// about somebody. camo: Option, + /// Matches by our own record of them. None where the row could not be + /// counted, for the same reason camo's is optional. + matches: Option, +} + +/// An em dash: what a figure nothing records yet is drawn as, here and on the +/// page. Two of the four are this today. +const NOTHING: &str = "\u{2014}"; + +impl Player { + /// How the player stands, read against itself. + /// + /// Neither has a source yet and both are declared anyway, the way the + /// pilot card declares its own placeholders: the shape of the card is + /// what is being decided, and one that showed only what is easy to count + /// would be the wrong shape. See plan/leaderboard.md for the rating and + /// plan/match-records.md for the attribution a kill ratio needs. + fn scores(&self) -> Vec<(String, String)> { + vec![ + ("Rating".to_owned(), NOTHING.to_owned()), + ("K/D".to_owned(), NOTHING.to_owned()), + ] + } + + /// What the player has done, counted. Read one at a time. + fn activity(&self) -> Vec<(String, String)> { + vec![ + ( + "Matches".to_owned(), + self.matches + .map_or_else(|| NOTHING.to_owned(), |n| n.to_string()), + ), + ("Forces".to_owned(), NOTHING.to_owned()), + // "Camo" is what the editor is called and not what a player has + // eight of: nobody says they own a camo. Patterns is what the + // things themselves are, here and in MegaMek's own vocabulary. + ( + "Patterns".to_owned(), + self.camo + .map_or_else(|| NOTHING.to_owned(), |n| n.to_string()), + ), + ] + } + + /// What the card is handed, however it is going to be drawn. + fn drawn(&self) -> card::Player<'_> { + card::Player { + name: &self.name, + avatar: self.avatar.clone(), + scores: self.scores(), + activity: self.activity(), + // Nothing writes a bio, nothing awards a medal and nothing says + // what faction anybody flies for. The card leaves room and draws + // none of them. + bio: None, + medals: 0, + faction: false, + } + } } impl Player { @@ -85,17 +153,26 @@ fn urlencoding(did: &str) -> String { /// Resolve the address, or answer that nobody is there. async fn look_up(state: &AppState, actor: &str) -> Option { let found = state.atproto.resolve_actor(actor).await?; - let camo = state.atproto.camo_count(&found.did).await; Some(Player { name: match &found.handle { Some(handle) => format!("@{handle}"), None => found.did.clone(), }, + camo: state.atproto.camo_count(&found.did).await, + matches: state.db.match_count(&found.did).await.ok(), + avatar: None, did: found.did, - camo, }) } +/// The same, with the picture. Only the card needs the bytes, and they are +/// the largest thing either route fetches. +async fn look_up_with_face(state: &AppState, actor: &str) -> Option { + let mut player = look_up(state, actor).await?; + player.avatar = state.atproto.avatar(&player.did).await; + Some(player) +} + /// Where the site is published, which is not this service's own address. fn site(state: &AppState) -> String { state @@ -129,7 +206,7 @@ pub async fn player(State(state): State, Path(actor): Path) -> /// The picture that preview points at. pub async fn player_card(State(state): State, Path(actor): Path) -> Response { - let Some(player) = look_up(&state, &actor).await else { + let Some(player) = look_up_with_face(&state, &actor).await else { return ( StatusCode::NOT_FOUND, headers("text/plain", "public, max-age=60"), @@ -137,17 +214,7 @@ pub async fn player_card(State(state): State, Path(actor): Path".to_owned(), did: "did:plc:abc123".to_owned(), + avatar: None, camo: None, + matches: None, }; let html = document( &hostile, @@ -280,7 +351,9 @@ mod tests { let bare = Player { name: "did:plc:abc123".to_owned(), did: "did:plc:abc123".to_owned(), + avatar: None, camo: None, + matches: None, }; let html = document( &bare, @@ -302,30 +375,95 @@ mod tests { } } - /// The card is drawn from type alone, so it is drawn for anybody - an - /// account with no picture, no handle and no camo included. + /// The card is drawn from type alone where it has to be, so it is drawn + /// for anybody - an account with no picture, no handle and no figures + /// included. #[test] fn a_card_is_drawn_for_an_account_with_nothing_on_it() { - let drawn = card::draw_player(&card::Player { - name: "did:plc:abc123", - did: "did:plc:abc123", - stats: vec![("Camo".to_owned(), "\u{2014}".to_owned())], - }); - let bytes = drawn.expect("a player card is drawn"); + let bare = Player { + name: "did:plc:abc123".to_owned(), + did: "did:plc:abc123".to_owned(), + avatar: None, + camo: None, + matches: None, + }; + let bytes = card::draw_player(&bare.drawn()).expect("a player card is drawn"); assert_eq!(&bytes[1..4], b"PNG"); keep(&bytes); } - /// The ordinary one: a handle, the DID under it, and a figure. + /// The card, written out so a change to the drawing can be looked at + /// rather than only asserted about. Writes nothing unless LANCE_CARD_DIR + /// names somewhere to write. + /// + /// LANCE_CARD_DIR=/tmp/cards LANCE_AVATAR=avatar.jpg \ + /// cargo test -p headquarters-api unfurl::tests::the_card_is_drawn + /// + /// Three of them: as served, with the bio and medals nothing fills yet, + /// and with no picture. The middle one is what the room being held back + /// is for, and the reason the shape leaves it. #[test] - fn a_card_is_drawn_for_an_account_with_a_handle() { - let bytes = card::draw_player(&card::Player { - name: "@a.example", - did: "did:plc:abc123", - stats: vec![("Camo".to_owned(), "8".to_owned())], - }) - .expect("a player card is drawn"); - assert_eq!(&bytes[1..4], b"PNG"); - keep_as("LANCE_CARD_OUT_NAMED", &bytes); + fn the_card_is_drawn() { + let Ok(dir) = std::env::var("LANCE_CARD_DIR") else { + return; + }; + let avatar = std::env::var("LANCE_AVATAR") + .ok() + .and_then(|path| std::fs::read(path).ok()); + let subject = Player { + name: "@permadeath.com".to_owned(), + did: "did:plc:nlzmjyfv6loqtxyzvdcznwgf".to_owned(), + avatar, + camo: Some(8), + matches: Some(12), + }; + const BIO: &str = "Snollygoster brabble nudiustertian, absquatulate vellichor \ + gongoozler mumpsimus cattywampus taradiddle skedaddle widdershins."; + + for (name, card) in [ + ("served", subject.drawn()), + ( + "filled", + card::Player { + bio: Some(BIO.to_owned()), + medals: 5, + faction: true, + ..subject.drawn() + }, + ), + ( + "no-face", + card::Player { + avatar: None, + ..subject.drawn() + }, + ), + ] { + let bytes = card::draw_player(&card).expect("a player card is drawn"); + std::fs::write(format!("{dir}/card-{name}.png"), &bytes).expect("written"); + } + } + + /// A figure that could not be read and a figure nothing records are both + /// an em dash, and a zero is neither of them. + #[test] + fn nothing_recorded_and_nothing_read_are_both_a_dash() { + let unread = Player { + name: "@a.example".to_owned(), + did: "did:plc:abc123".to_owned(), + avatar: None, + camo: None, + matches: Some(0), + }; + let activity = unread.activity(); + assert_eq!(activity[0], ("Matches".to_owned(), "0".to_owned())); + assert_eq!(activity[1].1, NOTHING, "Forces has no source yet"); + assert_eq!( + activity[2].1, NOTHING, + "an unread repository is not zero camo" + ); + for score in unread.scores() { + assert_eq!(score.1, NOTHING, "{} has no source yet", score.0); + } } }