//! The public face of a finished match: one page per result, and the picture //! a link to it unfurls with. //! //! Everything else this API serves is behind a session, and this is the one //! thing that cannot be. A player who posts their match to Bluesky is posting //! a URL that Bluesky's card service fetches with no cookie of theirs, and a //! preview nobody can fetch is a bare link. So these two routes are open, and //! what they say is bounded by that: the result document a match produced - //! handles, sides, what was left standing - and nothing about the account //! behind a handle, the session that launched the match, or any artifact //! except the one board render arena publishes for this purpose. //! //! Reachable only with the match id, which is a version 4 UUID: not secret, //! but not enumerable either, and it is already in the player's own address //! bar. A match with no result answers 404 - there is nothing to show and //! nothing to unfurl - so a page exists exactly when a fight finished. //! //! The words are built here rather than on the site because the site is a //! static build: a per-match og:image needs a per-match document, and there //! is no per-match file to prerender. use axum::extract::{Path, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{Html, IntoResponse, Response}; use crate::card; use crate::routes::AppState; /// How a match was entered, which is what the top of its page says. /// /// A daily challenge, a duel and a scenario are three different things to /// have played, and a page that presents them identically throws away the /// one piece of context a reader arriving from a link has none of. The site /// calls them by these names; so does this. enum Entry { /// The day's challenge, and the day it ran. The challenge's own name /// comes from the label like every other scenario's does. Daily { day: String }, /// Two or more people. What the site's Duel card opens. Duel, /// Anything else: a scenario played against Princess. Scenario, } impl Entry { fn eyebrow(&self) -> &'static str { match self { Entry::Daily { .. } => "Daily challenge", Entry::Duel => "Duel", Entry::Scenario => "Scenario", } } } /// What a scenario says about itself, for the cards that draw it. /// /// Read through the `mms` crate rather than restated here: the front page /// places a challenge's forces by the same `deploy` field and prints the same /// `description`, and two readings of one file drift. #[derive(Default)] pub struct Brief { /// The scenario's own description, which the front page prints under the /// challenge's name. blurb: String, /// Faction name to the zone it starts in. zones: Vec<(String, String)>, } impl Brief { /// From a scenario's text. Empty for a match whose scenario this service /// does not carry - a catalog fight lives in the arena image. fn read(text: &str) -> Brief { let blurb = headquarters_mms::parse(text) .ok() .and_then(|scenario| scenario.description().map(str::to_owned)) .unwrap_or_default(); let zones = headquarters_mms::factions(text) .into_iter() .filter_map(|faction| faction.deploy.map(|zone| (faction.name, zone))) .collect(); Brief { blurb, zones } } fn zone_of(&self, slot: &str) -> Option { self.zones .iter() .find(|(name, _)| name == slot) .map(|(_, zone)| zone.clone()) } } /// What the page knows about the match beyond its result: what was fought, /// how it was reached, and when. /// /// None of it is in the result document - arena records what happened, not /// what it was called - so it is read off the match row and its seats. pub struct Context { entry: Entry, /// The daily's slug, for looking its scenario back up. slug: Option, /// The scenario's name, as a person would say it. scenario: Option, /// The day the match was played, as YYYY-MM-DD. played: Option, /// How long it took, in seconds, from playable to decided. took: Option, } impl Context { /// Read from the row, its seats and the daily catalog. /// /// A duel is two or more people rather than a flag on the row: the site's /// Duel card and its Scenario card launch the same kind of match, and /// what makes it a duel is that somebody else was in it. pub fn read(row: &crate::db::MatchRow, seats: &[crate::db::MatchPlayer]) -> Context { let label = row.scenario.clone().unwrap_or_default(); let entry = if let Some(slug) = label.strip_prefix("daily:") { Entry::Daily { day: crate::matches::daily::challenge(slug) .map(|c| c.date.to_owned()) .unwrap_or_default(), } } else if seats.iter().filter(|seat| seat.control == "human").count() > 1 { Entry::Duel } else { Entry::Scenario }; Context { slug: label.strip_prefix("daily:").map(str::to_owned), scenario: scenario_name(&label), played: day_of(row.created_at), // Measured by the API rather than by the match: the row is // stamped when the container says it is playable and again when // it says the game is decided. That spans the lobby, so it is // how long the match took rather than how long it was fought. took: match (row.started_at, row.ended_at) { (Some(from), Some(to)) if to > from => Some(to - from), _ => None, }, entry, } } /// The line under the title: what was played, and when. fn line(&self) -> String { let mut parts: Vec = Vec::new(); if let Some(name) = &self.scenario { parts.push(escape(name)); } if let Entry::Daily { day } = &self.entry && !day.is_empty() { parts.push(escape(day)); } else if let Some(played) = &self.played { parts.push(escape(played)); } parts.join(" · ") } /// What the scenario says about itself, when this service has the file. /// Only a daily's is compiled in; a catalog scenario lives in the arena /// image and this is empty for it. pub fn brief(&self) -> Brief { match &self.entry { Entry::Daily { .. } => self .slug .as_deref() .and_then(crate::matches::daily::challenge) .map(|challenge| Brief::read(challenge.scenario)) .unwrap_or_default(), _ => Brief::default(), } } /// How the match is designated on a card: "SCENARIO: FIRST RUN". The kind /// on its own is a label; with the name it is what was fought. /// /// Except on the daily card, whose headline *is* the challenge's name - /// there the label stays a label rather than saying it twice. fn designation(&self, layout: card::Layout) -> String { match &self.scenario { Some(name) if layout != card::Layout::Daily => { format!("{}: {}", self.entry.eyebrow(), name) } _ => self.entry.eyebrow().to_owned(), } } /// The faint line at the foot of a card: when it was fought, how long it /// ran, and how long it took. /// /// Not the scenario's name - the designation across the top already says /// that, and a card should not introduce itself twice. Match length is /// absent for now: arena records when a match was decided and not how /// long it took, so this says rounds until the result carries a clock. fn footer_line(&self, round: i64) -> String { let mut parts: Vec = Vec::new(); if let Entry::Daily { day } = &self.entry && !day.is_empty() { parts.push(day.clone()); } else if let Some(played) = &self.played { parts.push(played.clone()); } parts.push(format!( "{round} round{}", if round == 1 { "" } else { "s" } )); if let Some(took) = self.took { parts.push(elapsed(took)); } parts.join(" · ") } } /// A duration, as long as it needs to be and no longer: "8m", "1h 12m", and /// seconds only for a match that ended before a minute was up. fn elapsed(seconds: i64) -> String { let minutes = seconds / 60; if minutes == 0 { return format!("{seconds}s"); } let (hours, minutes) = (minutes / 60, minutes % 60); if hours == 0 { format!("{minutes}m") } else { format!("{hours}h {minutes}m") } } /// A scenario's name from the label the row carries. /// /// A daily's slug is a file name and reads as one ("bug-company"), so it is /// turned back into words. A catalog match carries the path into the arena /// image's library, whose last element is the scenario's own file name. fn scenario_name(label: &str) -> Option { let raw = match label.strip_prefix("daily:") { Some(slug) => slug.to_owned(), None => { if label.is_empty() { return None; } label .rsplit('/') .next() .unwrap_or(label) .trim_end_matches(".mms") .to_owned() } }; if raw.is_empty() { return None; } // A file name is not a title. Three conventions the libraries actually // use, in order: the training scenarios number themselves ("1-FirstRun"), // the dailies are slugs ("bug-company"), and both run words together in // camel case where a person would put a space. let unnumbered = match raw.split_once(['-', '_', ' ']) { Some((head, tail)) if !head.is_empty() && head.chars().all(|c| c.is_ascii_digit()) => tail, _ => &raw, }; let mut spaced = String::with_capacity(unnumbered.len() + 4); let mut previous = ' '; for c in unnumbered.chars() { // Only after a lower-case letter, so "PPC" and "LRM" survive whole. if c.is_uppercase() && previous.is_lowercase() { spaced.push(' '); } spaced.push(if c == '-' || c == '_' { ' ' } else { c }); previous = c; } let mut out = String::with_capacity(spaced.len()); for word in spaced.split_whitespace() { if !out.is_empty() { out.push(' '); } let mut chars = word.chars(); match chars.next() { // Left alone when it already carries capitals: a catalog file is // named "Monte Diablo", not "monte diablo". Some(first) if word.chars().any(char::is_uppercase) => { out.push(first); out.push_str(chars.as_str()); } Some(first) => { out.extend(first.to_uppercase()); out.push_str(chars.as_str()); } None => {} } } Some(out) } /// The day a Unix timestamp falls on, UTC, as YYYY-MM-DD. /// /// Days rather than minutes: a match page is read long after the match, and /// the hour it was played at is nobody's business but the players'. fn day_of(seconds: i64) -> Option { if seconds <= 0 { return None; } // Howard Hinnant's civil-from-days, the same algorithm the launch // manifest dates itself with. let days = seconds.div_euclid(86_400) + 719_468; let era = days.div_euclid(146_097); let doe = days.rem_euclid(146_097); let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let year = if m <= 2 { y + 1 } else { y }; Some(format!("{year:04}-{m:02}-{d:02}")) } /// Whose match this is, when somebody asked for their own view of it. /// /// "@a beat @b" is the honest phrasing of a result and an awkward thing to /// post about yourself, and it falls apart entirely in a 2v2, where two /// people won and two lost and each of the four has a different sentence to /// write. So a card can be asked for from one player's point of view: they /// are "you", their side is the one drawn in detail, and the verdict is /// theirs. Named by handle in the URL because that is what a player would /// type and paste; matched against the handles the result recorded. #[derive(Clone)] pub struct Perspective { handle: String, } impl Perspective { /// From `?perspective=`, with or without its leading `@`. fn read(raw: Option<&str>) -> Option { let handle = raw?.trim().trim_start_matches('@').to_lowercase(); if handle.is_empty() { return None; } Some(Perspective { handle }) } /// Whether a force is theirs. Forces are named "@handle" in the result. fn owns(&self, force: &Force) -> bool { force.name.trim_start_matches('@').to_lowercase() == self.handle } } /// The one word a card shouts, when it was asked for from a side. /// /// VICTORY and DEFEAT are what a player is posting; "@a won" is a caption on /// it. Empty from nobody's point of view, where there is no single verdict to /// shout - a 2v2 seen from outside had two of each. fn verdict_word(summary: &Summary, perspective: Option<&Perspective>) -> &'static str { let Some(perspective) = perspective else { return ""; }; let Some(mine) = summary.forces.iter().find(|f| perspective.owns(f)) else { return ""; }; if !summary.decided() { return "DRAW"; } if summary.won(mine) { "VICTORY" } else { "DEFEAT" } } /// The verdict, phrased for whoever asked. /// /// From nobody's point of view it is a report: "@a beat @b". From a player's /// it is theirs to post: "you won", said with their own handle so the card /// still names somebody when it is read by a stranger. fn verdict_line(summary: &Summary, perspective: Option<&Perspective>) -> String { let Some(perspective) = perspective else { return summary.title(); }; let Some(mine) = summary.forces.iter().find(|f| perspective.owns(f)) else { return summary.title(); }; if !summary.decided() { return format!("{} drew", mine.name); } if summary.won(mine) { format!("{} won", mine.name) } else { format!("{} lost", mine.name) } } /// The line beside the way in, for a reader who arrived from a shared post /// and has never heard of any of this. pub(crate) const TAGLINE: &str = "Tactical combat on the Atmosphere."; /// The alpha feedback survey, linked from the foot of a match report. /// /// This page is where a player lands when a match ends: the screen inside the /// container hands off to it within a few seconds of the result existing, and /// unlike that screen it stays put. It is also public, so a stranger who /// followed a shared link sees the link too - which is the same bargain the /// "Play lance.blue" button beside it already makes. /// /// A constant, the way the flare board's address is, and `web/src/survey.ts` /// and `arena/suramadu/web/survey.js` carry their own copies of it: three /// bundles that never share code, one form. const SURVEY_URL: &str = "https://docs.google.com/forms/d/e/1FAIpQLSdT7hshO7t3JhbvdTB74ixx8D0923ViqDwnhsUXcrYHSwTMFQ/viewform"; /// What the link is called. A control's name, not copy. const SURVEY_LABEL: &str = "Alpha survey"; /// Which of MegaMek's own default silhouettes a machine falls back to. /// /// A path under the release's `data/images/units`, so it is fetched and /// addressed exactly like a design's own art - there is one library and one /// pipeline, and nothing about a machine's picture is committed here. /// /// The names are MegaMek's, from the `default_*` keys in its `mekset.txt`, /// and the Mek bands are the game's own weight classes. This is what a design /// with no art of its own gets in MegaMek, which makes it the right answer /// for every machine helm's index does not carry - every vehicle, platoon and /// fighter, since the index holds Meks alone. /// /// `None` for a kind MegaMek has no default for, and the machine is drawn /// without a picture rather than as a guess at what it might look like. fn sprite_for(unit: &serde_json::Value) -> Option<&'static str> { let kind = unit .get("kind") .and_then(serde_json::Value::as_str) .unwrap_or("unit"); let tons = unit .get("tons") .and_then(serde_json::Value::as_f64) .unwrap_or(0.0); Some(match kind { // The weight classes, which MegaMek draws a silhouette per. The old // three-way split sent every medium Mek to a Timber Wolf and every // assault to an Atlas - a specific machine standing in for a class. "mek" if tons > 100.0 => "defaults/default_super_heavy_mek.png", "mek" if tons >= 80.0 => "defaults/default_assault.png", "mek" if tons >= 60.0 => "defaults/default_heavy.png", "mek" if tons >= 40.0 => "defaults/default_medium.png", "mek" if tons >= 20.0 => "defaults/default_light.png", "mek" => "defaults/default_ultra_light.png", "battle armor" => "defaults/default_ba.png", "infantry" => "defaults/default_infantry_platoon.png", "protomek" => "defaults/default_proto.png", "aerospace" => "defaults/asf.png", "spacecraft" => "defaults/dropship_spheroid.png", "vtol" => "defaults/default_vtol.png", // Tracked is MegaMek's generic ground vehicle. It has wheeled, hover // and naval too, but which one a machine is is its motive type and // the result document does not carry it. "vehicle" => "defaults/default_tracked.png", _ => return None, }) } /// MegaMek's own constants, as the result document reports them. const TEAM_NONE: i64 = 0; const PLAYER_NONE: i64 = -1; /// The seats MegaMek holds for us rather than for a player: the watcher that /// spools turn reports, and the seat a browser takes in a match nobody is /// playing. Dropped by name, and by never having owned a unit. /// /// **Not** by the `observer` flag, which cannot be used for this. MegaMek /// sets it on any player holding no entities outside the lounge, so a force /// that was wiped out ends the match flagged as a watcher - and arena records /// each seat as the game last described it. A side that lost every machine is /// the loser, not a spectator, and dropping it leaves a decided match with no /// loser in it to name. const WATCHERS: [&str; 2] = ["ArenaObserver", "ArenaSpectator"]; struct Force { name: String, /// Whether Princess flew it. Bot seats fight under the site's own handle, /// which is a person's handle too. bot: bool, /// The manifest's slot key, which is what the match staged this force's /// camo under. slot: String, team: i64, id: i64, units_remaining: i64, units_start: i64, bv_remaining: i64, bv_start: i64, /// Each machine as arena recorded it at victory, in the order the game /// held them. Empty for a result from an arena older than `UnitReport` - /// the page is the same page without them, one section shorter. units: Vec, } struct Summary { round: i64, forces: Vec, /// The winning team, or None where the document records none. victory_team: Option, /// The winning player id, for the single-player win MegaMek records /// instead of a team one. victory_player: Option, } fn int(value: Option<&serde_json::Value>, fallback: i64) -> i64 { value .and_then(serde_json::Value::as_i64) .unwrap_or(fallback) } fn summarise(result: &serde_json::Value) -> Option { // Which seats Princess flew, from the identity map arena attaches to the // result. A bot fights under the site's own handle - which is also a real // account - so this is the only thing that tells the two apart. let bot_slots: Vec<&str> = result .get("identity") .and_then(|identity| identity.get("players")) .and_then(serde_json::Value::as_array) .map(|players| { players .iter() .filter(|seat| { seat.get("control").and_then(serde_json::Value::as_str) != Some("human") }) .filter_map(|seat| seat.get("slot").and_then(serde_json::Value::as_str)) .collect() }) .unwrap_or_default(); let players = result.get("players")?.as_array()?; let mut forces = Vec::new(); for player in players { let name = player .get("name") .and_then(serde_json::Value::as_str) .unwrap_or("") .to_owned(); let slot = player.get("slot").and_then(serde_json::Value::as_str); if WATCHERS.contains(&name.as_str()) || slot.is_some_and(|s| WATCHERS.contains(&s)) { continue; } // A seat that never owned a unit did not fight, whatever it is called. let units_start = int(player.get("unitsStart"), 0); if units_start <= 0 { continue; } forces.push(Force { bot: slot.is_some_and(|s| bot_slots.contains(&s)), name, slot: player .get("slot") .and_then(serde_json::Value::as_str) .unwrap_or_default() .to_owned(), team: int(player.get("team"), 0), id: int(player.get("id"), PLAYER_NONE), units_remaining: int(player.get("unitsRemaining"), 0), units_start, bv_remaining: int(player.get("bvRemaining"), 0), bv_start: int(player.get("bvStart"), 0), units: player .get("units") .and_then(serde_json::Value::as_array) .cloned() .unwrap_or_default(), }); } if forces.is_empty() { return None; } let team = int(result.get("victoryTeam"), TEAM_NONE); let player = int(result.get("victoryPlayerId"), PLAYER_NONE); Some(Summary { round: int(result.get("round"), 0), forces, victory_team: (team != TEAM_NONE).then_some(team), victory_player: (player != PLAYER_NONE).then_some(player), }) } impl Summary { /// Whether this force won, as the document recorded it rather than as the /// numbers imply: a forced victory leaves the loser's units standing, so /// counting them gets that case exactly backwards. fn won(&self, force: &Force) -> bool { match (self.victory_team, self.victory_player) { (Some(team), _) => force.team == team, (None, Some(id)) => force.id == id, _ => false, } } fn decided(&self) -> bool { self.victory_team.is_some() || self.victory_player.is_some() } /// The headline: who beat whom, or that nobody did. fn title(&self) -> String { let winners: Vec<&str> = self .forces .iter() .filter(|f| self.won(f)) .map(|f| f.name.as_str()) .collect(); let losers: Vec<&str> = self .forces .iter() .filter(|f| !self.won(f)) .map(|f| f.name.as_str()) .collect(); if !self.decided() || winners.is_empty() || losers.is_empty() { return format!("{} - a drawn match", self.line(" vs ")); } format!("{} beat {}", winners.join(" and "), losers.join(" and ")) } fn line(&self, joiner: &str) -> String { self.forces .iter() .map(|f| f.name.as_str()) .collect::>() .join(joiner) } /// What is left of each side, in one sentence. /// The sentence under the card in a feed. /// /// Read out loud rather than tabulated: this is the line everyone who /// scrolls past sees, and "@a: 1/1 units, 1639/1842 BV" is a log entry. /// Battle value as the fraction it is, because "89% of its battle value /// standing" says how the fight went and 1639 says nothing without the /// other number beside it. fn description(&self) -> String { let parts: Vec = self .forces .iter() .map(|f| { let share = if f.bv_start > 0 { f.bv_remaining * 100 / f.bv_start } else { 0 }; format!( "{} finished with {} of {} machine{} standing and {share}% of their battle value", f.name, f.units_remaining, f.units_start, if f.units_start == 1 { "" } else { "s" }, ) }) .collect(); format!("Decided in round {}. {}.", self.round, parts.join(". ")) } } /// Where each location sits on its machine, for the grid below. /// /// A body plan per class, because they are not the same shape and a /// location's place on the diagram is information: a Mek's arms flank its /// torsos, and a tank is a cross - front over body, sides either side of it, /// rear underneath. MegaMek's own abbreviations, laid out the way a record /// sheet lays them out. fn place_of(abbr: &str, sheet: Sheet) -> Option<&'static str> { Some(match sheet { // Turrets sit between the body and the rear rather than in the middle // of the cross: a vehicle can carry two, and neither is a face of the // hull. Sheet::Vehicle => match abbr { "FR" => "fr", "LS" => "ls", "BD" => "bd", "RS" => "rs", "TU" => "tu", "FT" => "ft", "RR" => "rr", _ => return None, }, // Head over the torsos, arms beside them, legs under. A quad's front // legs stand where a biped's arms do. _ => match abbr { "HD" => "hd", "CT" => "ct", "LT" => "lt", "RT" => "rt", "LA" | "FLL" => "la", "RA" | "FRL" => "ra", "LL" | "RLL" => "ll", "RL" | "RRL" => "rl", _ => return None, }, }) } /// One bar and the pair of numbers behind it. /// /// The numbers are printed because a bar cannot be read to a pip, and a /// record sheet is counted rather than eyeballed. fn meter(label: &str, left: i64, max: i64, kind: &str) -> String { let width = if max > 0 { (left * 100 / max).clamp(0, 100) } else { 0 }; let hurt = if max > 0 && left < max { " hurt" } else { "" }; format!( "
{label}\ \ {left}/{max}
" ) } /// What the machine had left to shoot with. /// /// Bins rather than a total: two bins of the same type are two different /// amounts, and a machine that walked away with one round in one bin is a /// different story from one that walked away loaded. fn ammo(unit: &serde_json::Value) -> String { let Some(bins) = unit.get("ammo").and_then(serde_json::Value::as_array) else { return String::new(); }; if bins.is_empty() { return String::new(); } let mut rows = String::new(); for bin in bins { let shots = int(bin.get("shots"), 0); let max = int(bin.get("shotsMax"), 0); let blown = bin.get("destroyed").and_then(serde_json::Value::as_bool) == Some(true); let note = if blown { " destroyed" } else if shots == 0 { " dry" } else { "" }; rows.push_str(&format!( "
  • {name}\ {loc}\ {shots}/{max}{note}
  • ", name = escape( bin.get("name") .and_then(serde_json::Value::as_str) .unwrap_or("Ammo") ), loc = escape( bin.get("loc") .and_then(serde_json::Value::as_str) .unwrap_or("") ), )); } format!("

    Ammunition

      {rows}
    ") } /// Which record sheet a machine gets. /// /// A class of machine is a class of paper: a Mek's sheet is an armour diagram /// and twelve numbered critical slots per location, a vehicle's is a diagram /// and a table of the things that go wrong with vehicles, a platoon's is a /// count of the people in it. Anything else gets [`Sheet::Plain`], which says /// so rather than drawing a Mek's shape around a machine that is not one. #[derive(Clone, Copy, PartialEq)] enum Sheet { Mek, Vehicle, Infantry, Plain, } fn sheet_class(unit: &serde_json::Value) -> Sheet { match unit.get("kind").and_then(serde_json::Value::as_str) { Some("mek") => Sheet::Mek, Some("vehicle") | Some("vtol") => Sheet::Vehicle, Some("infantry") | Some("battle armor") => Sheet::Infantry, _ => Sheet::Plain, } } /// The numbered critical slots of one location, the way the paper sheet /// prints them: every line in its place, empties included, damage struck /// through where it landed. /// /// Drawn inside the location's own box on the diagram, which is where a /// record sheet puts it - the head's six lines are printed in the head. The /// tally this replaces ("11 of 12 slots") said how many slots went and never /// which, and which is what a reader opens a sheet for. /// /// Meks only. A vehicle has twenty-five slots a location and four of them /// filled, so printing them all would be a hundred and twenty-five lines of /// "Roll again" - which is why `Sheet::Vehicle` lists equipment instead. fn crit_column(location: &serde_json::Value) -> String { let Some(slots) = location.get("slots").and_then(serde_json::Value::as_array) else { return String::new(); }; if slots.is_empty() { return String::new(); } let mut rows = String::new(); for (n, slot) in slots.iter().enumerate() { if slot.is_null() { rows.push_str(&format!( "
  • {}Roll again
  • ", n + 1 )); continue; } let state = if slot.get("missing").and_then(serde_json::Value::as_bool) == Some(true) { "missing" } else if slot.get("destroyed").and_then(serde_json::Value::as_bool) == Some(true) { "destroyed" } else if slot.get("breached").and_then(serde_json::Value::as_bool) == Some(true) { "breached" } else if slot.get("hit").and_then(serde_json::Value::as_bool) == Some(true) { "hit" } else { "ok" }; rows.push_str(&format!( "
  • {n}{name}
  • ", n = n + 1, name = escape( slot.get("name") .and_then(serde_json::Value::as_str) .unwrap_or("Equipment") ), )); } format!("
      {rows}
    ") } /// Everything the machine carries, with what is left in it. /// /// The one section every class gets, because it is the one that does not /// depend on the shape of the machine - and for a class with no sheet of its /// own it is most of what there is to say. /// /// Identical items in the same place are counted rather than repeated, the /// way a record sheet prints them: a Griffin carries twelve heat sinks and /// "Heat Sink" written twelve times is a list nobody reads. Damage breaks the /// grouping, because a destroyed launcher and a working one are not the same /// line - which is the point of the section. fn equipment(unit: &serde_json::Value) -> String { let Some(items) = unit.get("equipment").and_then(serde_json::Value::as_array) else { return String::new(); }; if items.is_empty() { return String::new(); } struct Row { name: String, loc: String, state: &'static str, shots: String, count: usize, } let mut rows: Vec = Vec::new(); for item in items { let destroyed = item.get("destroyed").and_then(serde_json::Value::as_bool) == Some(true); let hit = item.get("hit").and_then(serde_json::Value::as_bool) == Some(true); let state = if destroyed { "destroyed" } else if hit { "hit" } else { "" }; // Ammunition is never grouped: two bins of the same type hold two // different amounts, and adding them up hides an empty one. let shots = match ( item.get("shots").and_then(serde_json::Value::as_i64), item.get("shotsMax").and_then(serde_json::Value::as_i64), ) { (Some(left), Some(max)) if max > 0 => format!("{left}/{max}"), _ => String::new(), }; let name = item .get("name") .and_then(serde_json::Value::as_str) .unwrap_or("Equipment") .to_owned(); let loc = item .get("loc") .and_then(serde_json::Value::as_str) .unwrap_or("") .to_owned(); let groupable = shots.is_empty(); match rows.last_mut() { Some(last) if groupable && last.name == name && last.loc == loc && last.state == state && last.shots.is_empty() => { last.count += 1; } _ => rows.push(Row { name, loc, state, shots, count: 1, }), } } let body: String = rows .iter() .map(|row| { let count = if row.count > 1 { format!(" x{}", row.count) } else { String::new() }; format!( "
  • {name}{count}\ {loc}{shots}
  • ", state = row.state, name = escape(&row.name), // An em dash rather than a gap: equipment with no location is // built into the machine - a Griffin's engine heat sinks are // ten of them - and a blank column reads as missing data. loc = if row.loc.is_empty() { "—".to_owned() } else { escape(&row.loc) }, shots = escape(&row.shots), ) }) .collect(); format!("

    Weapons and equipment

      {body}
    ") } /// What went wrong with a vehicle, which is mostly not in its slots: a locked /// turret, a wrecked drive train, a hit driver. fn vehicle_damage(unit: &serde_json::Value) -> String { let Some(tank) = unit.get("vehicle") else { return String::new(); }; if tank.is_null() { return String::new(); } let mut said: Vec = Vec::new(); if tank .get("turretLocked") .and_then(serde_json::Value::as_bool) == Some(true) { said.push("Turret locked".to_owned()); } let motive = int(tank.get("motiveDamage"), 0); if motive > 0 { said.push(format!("Motive damage {motive}")); } if tank.get("driverHit").and_then(serde_json::Value::as_bool) == Some(true) { said.push("Driver hit".to_owned()); } if tank .get("commanderHit") .and_then(serde_json::Value::as_bool) == Some(true) { said.push("Commander hit".to_owned()); } if let Some(stabs) = tank .get("stabilisers") .and_then(serde_json::Value::as_array) { for loc in stabs { if let Some(abbr) = loc.as_str() { said.push(format!("Stabiliser hit: {}", escape(abbr))); } } } let body = if said.is_empty() { // Said out loud. An empty section reads as "we did not look". "
  • None
  • ".to_owned() } else { said.into_iter() .map(|line| format!("
  • {line}
  • ")) .collect() }; format!("

    Vehicle damage

      {body}
    ") } /// A platoon's armour is its people. fn infantry_strength(unit: &serde_json::Value) -> String { let Some(troops) = unit.get("infantry") else { return String::new(); }; if troops.is_null() { return String::new(); } let left = int(troops.get("troopers"), 0); let max = int(troops.get("troopersMax"), 0); if max <= 0 { return String::new(); } format!( "

    Troopers

    {left} of {max} still standing

    {}", meter("T", left, max, "armor") ) } /// One machine's record sheet: every location, what covered it, what held it /// up, and what was in it. fn sheet(unit: &serde_json::Value, slots: bool) -> String { let plan = sheet_class(unit); let empty = Vec::new(); let locations = unit .get("locations") .and_then(serde_json::Value::as_array) .unwrap_or(&empty); let mut tiles = String::new(); for location in locations { let abbr = location .get("abbr") .and_then(serde_json::Value::as_str) .unwrap_or("?"); let internal = int(location.get("internal"), 0); let internal_max = int(location.get("internalMax"), 0); let blown = location .get("blownOff") .and_then(serde_json::Value::as_bool) == Some(true); let gone = internal_max > 0 && internal <= 0; let class = if blown { "loc off" } else if gone { "loc gone" } else { "loc" }; let style = match place_of(abbr, plan) { Some(place) => format!(" style=\"grid-area:{place}\""), None => String::new(), }; let state = if blown { "blown off" } else if gone { "destroyed" } else { "" }; let rear = match int(location.get("rearMax"), 0) { max if max > 0 => meter("R", int(location.get("rear"), 0), max, "armor"), _ => String::new(), }; tiles.push_str(&format!( "
    \

    {abbr}{state}

    {armor}{rear}{structure}{crits}
    ", name = escape( location .get("name") .and_then(serde_json::Value::as_str) .unwrap_or(abbr) ), abbr = escape(abbr), armor = meter( "A", int(location.get("armor"), 0), int(location.get("armorMax"), 0), "armor" ), structure = meter("S", internal, internal_max, "structure"), // Only where the sheet under it lists them. A vehicle carries // twenty-five slots a location with four of them filled, and a // strip of twenty-one empty boxes says nothing at all. // The slots, printed in the box they sit in. A vehicle's box // gets none: twenty-five lines a location with four of them // holding anything is not a record sheet, it is a wall. crits = if slots { crit_column(location) } else { String::new() }, )); } // A body plan the grid knows, or a plain flow of whatever locations the // machine has. Forcing an unknown shape into the Mek grid would stack // tiles on top of each other. let known = locations.iter().any(|location| { place_of( location .get("abbr") .and_then(serde_json::Value::as_str) .unwrap_or(""), plan, ) .is_some() }); let shape = match (known, plan) { (true, Sheet::Vehicle) => "sheet veh", (true, _) => "sheet mek", (false, _) => "sheet flow", }; format!("
    {tiles}
    ") } /// The crew, in one line: skills first, because battle value is computed from /// them, and what happened to them after. fn crew_line(unit: &serde_json::Value) -> String { let Some(crew) = unit.get("crew") else { return String::new(); }; let mut parts: Vec = Vec::new(); if let Some(name) = crew.get("name").and_then(serde_json::Value::as_str) && !name.is_empty() { parts.push(escape(name)); } let gunnery = crew.get("gunnery").and_then(serde_json::Value::as_i64); let piloting = crew.get("piloting").and_then(serde_json::Value::as_i64); if let (Some(g), Some(p)) = (gunnery, piloting) { parts.push(format!("{g}/{p}")); } if crew.get("dead").and_then(serde_json::Value::as_bool) == Some(true) { parts.push("killed".to_owned()); } else if crew.get("ejected").and_then(serde_json::Value::as_bool) == Some(true) { parts.push("ejected".to_owned()); } else { let hits = int(crew.get("hits"), 0); if hits > 0 { parts.push(format!("{hits} hit{}", if hits == 1 { "" } else { "s" })); } } parts.join(" · ") } /// What became of a machine, said the way a person would say it. The words /// are `suramadu/web/units.js`'s, because the two screens read the same /// document and should not describe it differently. Anything unrecognised is /// printed as MegaMek recorded it. fn status_word(status: &str) -> &str { match status { // "active" is MegaMek's word for a unit still on the board, and the // board is gone by the time anyone reads this page. "active" => "standing", "retreated" => "withdrew", "pushed" => "pushed off", other => other, } } /// How hurt a machine is, in a word. /// /// MegaMek's own `getDamageLevel`, which is a rules judgement about what the /// machine can still do rather than an average of its armour - a crippled Mek /// can be carrying most of its plate. Status wins where there is one: a /// machine that was destroyed is destroyed, whatever it was carrying a moment /// earlier. fn condition(unit: &serde_json::Value) -> String { let status = unit .get("status") .and_then(serde_json::Value::as_str) .unwrap_or("active"); if status != "active" { return status_word(status).to_owned(); } match unit.get("damage").and_then(serde_json::Value::as_str) { Some("crippled") => "crippled", Some("heavy") => "heavy damage", Some("moderate") => "damaged", Some("light") => "light damage", // Said out loud rather than left blank: a machine that came through a // fight untouched is the most interesting card on the screen. _ => "unhurt", } .to_owned() } /// Which of the states the tile is painted in. Separate from the words above, /// so the colour follows the meaning rather than the phrasing: a withdrawn /// machine is intact and a crippled one is not. fn tone(unit: &serde_json::Value) -> &'static str { let status = unit .get("status") .and_then(serde_json::Value::as_str) .unwrap_or("active"); if lost(status) { return "lost"; } if status != "active" { return "gone"; } match unit.get("damage").and_then(serde_json::Value::as_str) { Some("crippled") => "crippled", Some("heavy") => "heavy", Some("moderate") => "moderate", Some("light") => "light", _ => "whole", } } /// A machine that is not coming back. fn lost(status: &str) -> bool { matches!(status, "destroyed" | "devastated" | "salvage" | "captured") } /// The sprite a machine is drawn with: its design's own where the index /// carries it, and MegaMek's default for its class where it does not. /// /// One rule, because the page and the card have to agree about what a /// machine looks like - they are the same report, and one of them unfurls /// into the other. fn picture_for( unit: &serde_json::Value, units: Option<&crate::units::UnitIndex>, ) -> Option { let name = unit.get("name").and_then(serde_json::Value::as_str); let index = units?; let sprite = name .and_then(|name| index.design(name)) .map(str::to_owned) .or_else(|| sprite_for(unit).map(str::to_owned))?; Some(sprite) } /// 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. /// /// 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) .unwrap_or("Unknown machine"); let mut figures: Vec = Vec::new(); if let Some(tons) = unit.get("tons").and_then(serde_json::Value::as_f64) && tons > 0.0 { figures.push(format!("{tons:.0} t")); } let bv = int(unit.get("bv"), 0); if bv > 0 { figures.push(format!("{bv} BV")); } let armour = int(unit.get("armorPercent"), 0).clamp(0, 100); let status = unit .get("status") .and_then(serde_json::Value::as_str) .unwrap_or("active"); // No bar on a wreck: nought per cent of the armour of a machine that is // not there any more is not a fact about the fight. let bar = if lost(status) { String::new() } else { format!("") }; // MegaMek's own art, served from the site beside the release it belongs // to. Nothing when this deployment names no index, since the release the // art is published under is the index's own - and a machine with no // picture is drawn as its name and its condition rather than as a broken // image. let picture = picture_for(unit, units) .and_then(|sprite| units?.sprite_url(&sprite)) .map(|url| { format!( "\"\"", escape(&url) ) }) .unwrap_or_default(); format!( "{picture}\ {name}\ {figures}\ {condition}{bar}", tone = tone(unit), picture = picture, name = escape(name), figures = figures.join(" · "), condition = escape(&condition(unit)), ) } /// The record sheet, as a panel that is only on screen when it is asked for. /// /// `:target` and not a script: the reader is whoever the link was sent to, /// this page is served to them signed out, and a modal that needs JavaScript /// to open is a modal some of them cannot open. The address bar carries which /// machine is open, so the back button closes it and a link can be sent /// straight to one machine's sheet. fn unit_modal(id: &str, unit: &serde_json::Value) -> String { let name = unit .get("name") .and_then(serde_json::Value::as_str) .unwrap_or("Unknown machine"); let mut facts: Vec = Vec::new(); facts.push(escape(&condition(unit))); let crew = crew_line(unit); if !crew.is_empty() { facts.push(format!("Pilot {crew}")); } // Heat, for a machine that tracks it. MegaMek gives a vehicle a capacity // of 999, which is its way of saying the rules do not apply here; printed // as it stands it reads as a fact about the fight. let heat = int(unit.get("heat"), 0); let heat_max = int(unit.get("heatCapacity"), 0); if heat_max > 0 && heat_max < 100 { facts.push(format!("Heat {heat}/{heat_max}")); } // What this class of machine's sheet is made of. The armour diagram is // common to the three that have one; what sits under it is not. let class = sheet_class(unit); let body = match class { Sheet::Mek => format!("{}{}", sheet(unit, true), equipment(unit)), Sheet::Vehicle => format!( "{}{}{}", sheet(unit, false), vehicle_damage(unit), equipment(unit) ), Sheet::Infantry => format!("{}{}", infantry_strength(unit), equipment(unit)), // The fallback, and it says what it is. An aerospace fighter drawn // into a Mek's body plan would be a lie told neatly; this is the // armour it had, the gear it carried, and an honest note that nobody // has drawn this class its own sheet yet. Sheet::Plain => format!( "

    No record sheet for this class of machine yet - \ what follows is its armour and what it was carrying.

    {}{}", sheet(unit, false), equipment(unit) ), }; format!( "
    \ ×\

    {name}

    {facts}

    {body}{ammo}
    \
    ", name = escape(name), facts = facts.join(" · "), ammo = if class == Sheet::Infantry { String::new() } else { ammo(unit) }, ) } /// The address of one machine's sheet: which force, and which machine in it. /// Stable for the life of the page, because a reader can send it to somebody. fn unit_id(force: usize, unit: usize) -> String { format!("u{force}-{unit}") } /// The share block: the cards this match can post, one at a time, and the /// composer aimed at whichever is showing. /// /// A gallery rather than a list of names, because the thing being chosen is a /// picture. Every card this match can draw gets a slide with its own picture, /// its own permalink and its own composer, so what is on screen and what /// would be posted are the same thing. /// /// No script: the slides are a radio group, and the arrows and dots are /// labels for it. `:target` would be the shorter markup, but following a /// fragment scrolls the gallery to the top of the viewport on every press - /// the page jumps where only the card should turn. A radio changes nothing /// but its own state. /// /// The consequence worth knowing is that turning the gallery does not change /// this page's own og:image, which is fixed at render. It does not have to - /// each slide's post links to that card's address, and the unfurler reads the /// card from there. fn share_menu(page: &Page) -> String { // A reader who arrived from somebody's post is not posting it again. The // composer belongs to the player the match handed here, and nothing else // on this page depends on it. if !page.participant { return String::new(); } let slides: Vec<(card::Layout, String, String, String)> = page .offered .iter() .map(|layout| { let query = card_query(page.perspective, Some(*layout)); ( *layout, format!("{}{}", page.base, query), slide_id(*layout), pick_id(*layout), ) }) .collect(); // The one whose radio is checked at render, and so the one showing // before anything is pressed. let opening = page .offered .iter() .position(|layout| *layout == page.chosen) .unwrap_or(0); let mut out = String::from("
    "); for (index, (layout, address, id, pick)) in slides.iter().enumerate() { let previous = &slides[(index + slides.len() - 1) % slides.len()].3; let next = &slides[(index + 1) % slides.len()].3; let opens = if index == opening { " checked" } else { "" }; let turn = if slides.len() > 1 { format!( "\ ", previous = escape(previous), next = escape(next), ) } else { String::new() }; let dots: String = slides .iter() .map(|(other_layout, _, other, other_pick)| { format!( "", here = if other == id { " here" } else { "" }, other_pick = escape(other_pick), label = escape(other_layout.label()), ) }) .collect(); let compose = compose_url(address); out.push_str(&format!( "\
    \
    \ \"The\ {turn}
    \
    {label}{dots}
    \
    ", pick = escape(pick), id = escape(id), picture = escape(&format!( "{}/card.png{}", page.base, card_query(page.perspective, Some(*layout)) )), label = escape(layout.label()), compose = escape(&compose), )); } out.push_str("
    "); out } /// The id one card's slide carries. fn slide_id(layout: card::Layout) -> String { format!("card-{}", layout.slug()) } /// The id of the radio that shows one card's slide, and so what the arrows /// and dots on every other slide are labels for. fn pick_id(layout: card::Layout) -> String { format!("pick-{}", layout.slug()) } /// The query that names a point of view and a card. fn card_query(perspective: Option<&Perspective>, layout: Option) -> String { let mut parts: Vec = Vec::new(); if let Some(perspective) = perspective { parts.push(format!("perspective={}", urlencode(&perspective.handle))); } if let Some(layout) = layout { parts.push(format!("card={}", layout.slug())); } if parts.is_empty() { String::new() } else { format!("?{}", parts.join("&")) } } /// The Bluesky composer, prefilled with one address. fn compose_url(here: &str) -> String { // The link and nothing else. Everything a sentence here could say - who // won, what was played, which round - is already drawn on the card the // link unfurls into, and saying it twice reads as a caption on a caption. // // The link has to be in the text: `text` is the only parameter the intent // takes, and the composer builds the card from a URL it finds there. A // post with an empty body and only the card is reachable from here, by // deleting the link once the card attaches - which Bluesky keeps. format!("https://bsky.app/intent/compose?text={}", urlencode(here)) } /// Percent-encoding for a query value. Hand-rolled for the same reason the /// rest of this file is: one call site, and no reason to reach for a crate. pub(crate) fn urlencode(text: &str) -> String { let mut out = String::with_capacity(text.len() * 3); for byte in text.as_bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(*byte as char) } _ => out.push_str(&format!("%{byte:02X}")), } } out } /// Text into HTML. Everything interpolated into the page below goes through /// this: a handle is a name someone else chose, and it reaches here from a /// result document written inside a match container. pub(crate) fn escape(text: &str) -> String { let mut out = String::with_capacity(text.len()); for c in text.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), _ => out.push(c), } } out } /// Why a report cannot be shown. A report address gets visited with nothing /// behind it - a link shared mid-match, a mistyped id - and a bare 404 is a /// blank page. #[derive(Clone, Copy, Debug, PartialEq)] enum Missing { /// No such match, or none this deployment launched. Unknown, /// Launched, and no result yet because it is not over yet. Unfinished, /// Over, and no result that can be shown. The container failed, or died /// between deciding the match and uploading what it decided. Resultless, /// Whether there is a report could not be determined - the database did /// not answer, or this deployment has no artifact store. Says nothing /// about the match, so it must not be cached as an absence. Unreadable, } impl Missing { /// From the row's status. No container poll: this page is public, and /// saying "not yet" should not cost an AWS call. fn read(status: &str) -> Self { match status { "starting" | "running" | "ready" => Missing::Unfinished, _ => Missing::Resultless, } } fn heading(self) -> &'static str { match self { Missing::Unknown => "No such match", Missing::Unfinished => "This match is still being fought", Missing::Resultless => "This match ended without a result", Missing::Unreadable => "This report cannot be read right now", } } /// The line under it. fn line(self) -> &'static str { match self { Missing::Unknown => { "The link may be mistyped, or the match may have been launched somewhere else." } Missing::Unfinished => { "The report is written when the match is decided. This page will look again on its own." } Missing::Resultless => "Nothing was recorded for it, so there is no report to show.", Missing::Unreadable => "Something went wrong looking it up. This page will try again.", } } /// Whether the page reloads itself. Only where waiting helps: the other /// two never grow a result. fn refresh(self) -> bool { matches!(self, Missing::Unfinished | Missing::Unreadable) } /// Whether a report may still appear at this address. /// /// Unknown is the only permanent one. Resultless is not: the container /// retries the upload on its way out, so a match that is over without a /// result can gain one a minute later. Unreadable is not even an answer /// about the match - caching it would blank a live report for as long as /// it was held. fn may_yet(self) -> bool { !matches!(self, Missing::Unknown) } /// What to answer a machine with. /// /// 503 rather than 404 wherever a report may still appear, for two /// reasons. A card service that fetches a 404 records "this link has no /// preview" and a post made from it carries none, permanently - where a /// failure leaves the link bare and the next fetch gets the real card. /// And the CDN rewrites 404 distribution-wide into the site's static /// 404.html, so the page below would never reach a reader at all. fn status(self) -> StatusCode { if self.may_yet() { StatusCode::SERVICE_UNAVAILABLE } else { StatusCode::NOT_FOUND } } } /// The page at a report address with no report behind it. No og:image: the /// only one it could name is the card, which is refusing too - see `status` /// for what a machine gets and why. fn notice(missing: Missing, home: &str) -> String { let refresh = if missing.refresh() { "\n" } else { "" }; format!( "\n\n\n\ \n\ \n\ \n\ {refresh}\ {heading} - lance.blue\n\ \n\ \n\ \n\ \n\ \n\n\
    \n\

    Match report

    \n\

    {heading}

    \n\

    {line}

    \n\

    Play lance.blue\ {tagline}

    \n\

    lance.blue

    \n\
    \n\n\n", heading = missing.heading(), line = missing.line(), home = escape(home), tagline = TAGLINE, refresh = refresh, STYLE = STYLE, ) } /// A refusal with something to read. /// /// Never cached where a report may still appear: a cached refusal outlives /// the thing it describes, and every reader behind that cache - the CDN, a /// card service, a browser - keeps being told there is nothing here after /// there is. fn refuse(missing: Missing, home: &str) -> Response { ( missing.status(), refusal_headers(missing), Html(notice(missing, home)), ) .into_response() } /// The same refusal for the card, which is an image and has no page to show. fn refuse_card(missing: Missing) -> Response { (missing.status(), refusal_headers(missing)).into_response() } fn refusal_headers(missing: Missing) -> HeaderMap { let mut headers = HeaderMap::new(); if missing.may_yet() { headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); headers.insert(header::RETRY_AFTER, HeaderValue::from_static("30")); } else { headers.insert( header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=300"), ); } headers } /// The result of a match, for anyone with the link. pub async fn page( State(state): State, Path(id): Path, axum::extract::Query(ask): axum::extract::Query, ) -> Response { // No artifact store: a deployment fault, and nothing about this match. let Some(matches) = state.matches.as_ref() else { return refuse(Missing::Unreadable, &state.web_origin); }; // The row first: its status is what tells the refusals apart. A lookup // that fails is not an absent match - answering 404 to a database hiccup // caches a live report as gone. let row = match state.db.get_match(&id).await { Ok(Some(row)) => row, Ok(None) => return refuse(Missing::Unknown, &state.web_origin), Err(_) => { tracing::error!(match_id = id, "report: match lookup failed"); return refuse(Missing::Unreadable, &state.web_origin); } }; // What it was and how it was reached. A match with no seats recorded is // ordinary; a lookup that failed is not, and reading it as "no seats" // would frame a daily challenge as a plain scenario and cache that for // an hour. let seats = match state.db.match_players(&id).await { Ok(seats) => seats, Err(_) => { tracing::error!(match_id = id, "report: seat lookup failed"); return refuse(Missing::Unreadable, &state.web_origin); } }; let context = Context::read(&row, &seats); let Some(result) = matches.result(&id).await else { return refuse(Missing::read(&row.status), &state.web_origin); }; // Present but unusable is still nothing to show. let Some(summary) = summarise(&result) else { tracing::warn!(match_id = id, "report: result present but not summarisable"); return refuse(Missing::Resultless, &state.web_origin); }; let perspective = Perspective::read(ask.perspective.as_deref()); // 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_board(&id).await; let offered = layouts(&context, &summary, perspective.is_some(), board); let chosen = ask .card .as_deref() .and_then(card::Layout::from_slug) .filter(|wanted| offered.contains(wanted)) .unwrap_or(offered[0]); // The address this page is at, ask and all: it is what an unfurler reads // and what a share link has to reproduce, so a card someone chose is the // card their post carries. // Where this page is *published*, which is not always where it is served // from: with a CDN routing /reports/ to this service, the address a // reader copies and an unfurler fetches is the site's. let base = format!( "{}/reports/{}", state .share_origin .as_deref() .unwrap_or(matches.public_url()), id ); let here = format!("{base}{}", ask.query()); // Drawn per match and per ask: the address carries the layout and the // point of view it was asked from, so the picture a post unfurls with is // the one the sharer chose. Never missing - an unfurler that fetches // nothing shows a bare link, which is what this page exists to avoid - // because a result that cannot be summarised has already been refused // above. let image = format!("{base}/card.png{}", ask.query()); ( // 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. // // 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, &Page { here: &here, base: &base, image: &image, home: &state.web_origin, perspective: perspective.as_ref(), offered: &offered, chosen, participant: ask.participant.is_some(), units: state.units.as_deref(), }, )), ) .into_response() } /// The page itself. Pure text in, pure text out: an AppState is not needed to /// decide what a finished match looks like, and a document that can be built /// without one can be tested and looked at without one too. /// Where the page is, what it is showing, and for whom. struct Page<'a> { /// This page's own address, ask and all. here: &'a str, /// The same without the ask, for building links that carry a different /// one. base: &'a str, image: &'a str, home: &'a str, perspective: Option<&'a Perspective>, /// The cards this match can offer, and the one being shown. offered: &'a [card::Layout], 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 { let (here, image, home) = (page.here, page.image, page.home); // Named for whoever asked, when somebody did: a player posting their own // match should not have to write "@them beat @me". let title = verdict_line(summary, page.perspective); let description = summary.description(); let mut rows = String::new(); // Built alongside the list and appended after it: a :target panel that // lives inside the card it covers inherits that card's stacking, and // opens under it. let mut modals = String::new(); for (index, force) in summary.forces.iter().enumerate() { let share = if force.bv_start > 0 { force.bv_remaining * 100 / force.bv_start } else { 0 }; // Text, never a link. // // The match page a player reads links each handle to its account, // because everyone on that screen fought in the match. This page is // public and one of the names on it belongs to somebody who did not // choose to be shown to whoever the other player shares it with. // Naming them is unavoidable - it is their match too - but pointing an // audience at their account is not, so this page does not. let name = escape(&force.name); let mark = if summary.won(force) { " victor" } else { "" }; let machines: String = force .units .iter() .enumerate() .map(|(n, unit)| unit_tile(&unit_id(index, n), unit, page.units)) .collect(); let machines = if machines.is_empty() { String::new() } else { format!("
    {machines}
    ") }; modals.push_str( &force .units .iter() .enumerate() .map(|(n, unit)| unit_modal(&unit_id(index, n), unit)) .collect::(), ); rows.push_str(&format!( "
  • {name}{mark}

    \

    Team {} - units {}/{} - BV {}/{} ({share}%)

    \
    {machines}
  • ", force.team, force.units_remaining, force.units_start, force.bv_remaining, force.bv_start, )); } format!( "\n\n\n\ \n\ \n\ {title} - lance.blue\n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\n\
    \n\

    {eyebrow}

    \n\

    {title}

    \n\

    {context_line}{context_sep}Round {round}

    \n\ {share}\n\
      {rows}
    {modals}\n\

    Play lance.blue\ {tagline}

    \n\

    lance.blue\ · \ {survey_label}

    \n\
    \n\n\n", survey = SURVEY_URL, survey_label = SURVEY_LABEL, title = escape(&title), description = escape(&description), here = escape(here), image = escape(image), round = summary.round, eyebrow = context.entry.eyebrow(), context_line = context.line(), context_sep = if context.line().is_empty() { "" } else { " · " }, share = share_menu(page), tagline = TAGLINE, rows = rows, modals = modals, home = escape(home), STYLE = STYLE, ) } /// The board at the end, for the link preview and for the page itself. /// /// Streamed through rather than redirected to a presigned URL: a card service /// that follows a redirect to a signed S3 address caches the signature, and /// the picture stops loading when it expires. This one is a plain image at a /// plain address. pub async fn card( State(state): State, Path(id): Path, axum::extract::Query(ask): axum::extract::Query, ) -> Response { let Some(matches) = state.matches.as_ref() else { return refuse_card(Missing::Unreadable); }; let row = match state.db.get_match(&id).await { Ok(Some(row)) => row, Ok(None) => return refuse_card(Missing::Unknown), Err(_) => { tracing::error!(match_id = id, "report card: match lookup failed"); return refuse_card(Missing::Unreadable); } }; // Gated on the result, so a card is only public once the page that shows // it is: it is a picture of how somebody's match went. let Some(result) = matches.result(&id).await else { return refuse_card(Missing::read(&row.status)); }; let Some(summary) = summarise(&result) else { return refuse_card(Missing::Resultless); }; let seats = match state.db.match_players(&id).await { Ok(seats) => seats, Err(_) => { tracing::error!(match_id = id, "report card: seat lookup failed"); return refuse_card(Missing::Unreadable); } }; let context = Context::read(&row, &seats); let perspective = Perspective::read(ask.perspective.as_deref()); // The board it was fought on, and each force's own colours. Both are // artifacts of the match; both are optional, and the card is drawn // without whichever is missing. let board = matches.board(&id).await; let mut camo = Vec::with_capacity(summary.forces.len()); // A slot with no colours staged is ordinary and draws plain. A read that // failed is not: it means this card is missing something it should have, // and that is a caching decision rather than a drawing one. let mut colours_unread = false; for force in &summary.forces { match matches.camo(&id, &force.slot).await { Ok(found) => camo.push(found), Err(_) => { colours_unread = true; camo.push(None); } } } let offered = layouts(&context, &summary, perspective.is_some(), board.is_some()); let layout = ask .card .as_deref() .and_then(card::Layout::from_slug) .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); let brief = context.brief(); let drawn = card::draw(&compose( &summary, Drawn { layout, brief: &brief, eyebrow: &designation, verdict: verdict_word(&summary, perspective.as_ref()), title: &title, sub: &sub, perspective: perspective.as_ref(), camo: &camo, board: board.clone(), art: &art, }, )); // How long this picture is allowed to stand. // // A day once it is whole, because a finished match never changes. A // minute while any of it is missing - the ground, or colours S3 did not // hand over. The ground is the common case: arena sends the result the instant // the game is decided and the board render about five seconds later, and // the player is on this page inside three - so the first card of every // match is asked for in that gap. Cached for a day, that boardless first // draw would be the picture every reader of the post ever sees. let complete = board.is_some() && !colours_unread; let Some(bytes) = drawn.or(board) else { // Nothing drawn and no board to fall back on. Not an answer about // this match, so it is not cached as one. tracing::error!(match_id = id, "report card: nothing to send"); return refuse_card(Missing::Unreadable); }; let caching = if complete { "public, max-age=86400" } else { "public, max-age=60" }; ( [ (header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, caching), ], bytes, ) .into_response() } /// What a reader asked for: whose view, and which card. #[derive(serde::Deserialize, Default)] pub struct Ask { /// A handle, with or without its `@`. pub perspective: Option, /// A layout's slug. pub card: Option, /// Set by the hand-off out of a match, and by nothing else: it says the /// reader is the player who just fought, so the page offers them the /// composer. Deliberately absent from `query` below, so it reaches /// neither the og:url nor any link this page hands out - a reader who /// arrives from a post is a reader, not a participant. /// /// Not a permission. Anyone can add it, and all it unlocks is a button /// that composes a post the reader could write by hand; the page and the /// card behind it are public either way. pub participant: Option, } impl Ask { /// The query string that reproduces this ask, for a link or an og:url. fn query(&self) -> String { let mut parts: Vec = Vec::new(); if let Some(perspective) = &self.perspective { parts.push(format!("perspective={}", urlencode(perspective))); } if let Some(card) = &self.card { parts.push(format!("card={}", urlencode(card))); } if parts.is_empty() { String::new() } else { format!("?{}", parts.join("&")) } } } /// 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. /// 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); } if summary.forces.len() == 2 { out.push(card::Layout::Duel); } out.push(card::Layout::Dossier); // One force in detail only makes sense as somebody's own force. if perspective { out.push(card::Layout::Lance); } if board { out.push(card::Layout::Battlefield); } out } /// Build the picture from the result. /// /// The silhouette a machine gets is the one the page draws it with, and the /// camo is the force's own, read back out of the artifacts the match staged /// for arena. A force whose camo is missing is painted in MegaMek's grey, /// which is what an unpainted lance looks like in the game. /// What the card is drawn from, beyond the result itself: the words already /// phrased for whoever asked, and the pictures the match left behind. struct Drawn<'a> { layout: card::Layout, /// What the scenario says about itself, for the card that draws it. brief: &'a Brief, /// "SCENARIO: FIRST RUN", built by the caller so it outlives the card. eyebrow: &'a str, /// VICTORY, DEFEAT or DRAW, when the card was asked for from a side. verdict: &'a str, title: &'a str, sub: &'a str, perspective: Option<&'a Perspective>, /// 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 picture on the card, fetched once each. /// /// Distinct by sprite rather than per machine: a lance of four Locusts is one /// request, and so is a platoon that all falls back to the same default. Art /// that would not come down is simply absent, and that machine is drawn /// without a picture - 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 fetched: std::collections::HashMap>>> = std::collections::HashMap::new(); 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) = picture_for(unit, units) else { continue; }; let bytes = match fetched.get(&sprite) { Some(bytes) => bytes.clone(), None => { let bytes = index.sprite(&sprite).await; fetched.insert(sprite, bytes.clone()); bytes } }; if let Some(bytes) = bytes { 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; }; // The same rule `picture_for` applies: the design's own art, and // MegaMek's default for its class where the index has no entry. let Some(sprite) = sprites .get(name) .map(|sprite| (*sprite).to_owned()) .or_else(|| sprite_for(unit).map(str::to_owned)) 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> { let Drawn { layout, brief, eyebrow, verdict, title, sub, perspective, camo, board, art, } = drawn; let forces = summary .forces .iter() .enumerate() .map(|(index, force)| card::Force { name: &force.name, bot: force.bot, team: force.team, victor: summary.won(force), mine: perspective.map(|p| p.owns(force)).unwrap_or(false), units_remaining: force.units_remaining, units_start: force.units_start, // Clamped: a result reporting more battle value standing than // it started with is a bug upstream, and a bar drawn at 157% is // that bug repeated in public. bv_share: if force.bv_start > 0 { (force.bv_remaining * 100 / force.bv_start).clamp(0, 100) } else { 0 }, bv_remaining: force.bv_remaining, bv_start: force.bv_start, camo: camo.get(index).cloned().flatten(), zone: brief.zone_of(&force.slot), machines: force .units .iter() .map(|unit| { let name = unit .get("name") .and_then(serde_json::Value::as_str) .unwrap_or("Unknown machine"); card::Machine { name, // The design's own art where the index carries it, // MegaMek's default for its class where it does not, // and nothing at all when neither came down. sprite: art .get(name) .map(|bytes| std::borrow::Cow::Borrowed(bytes.as_slice())), condition: condition_word(unit), alive: !lost( unit.get("status") .and_then(serde_json::Value::as_str) .unwrap_or("active"), ), } }) .collect(), }) .collect(); card::Card { layout, verdict, blurb: &brief.blurb, eyebrow, title, sub, forces, board, } } /// What a card leads with, and what it says underneath. /// /// Every layout leads with the verdict except the daily challenge, which /// leads with the challenge: its name is what another player recognises, /// having played the same one that day, and the verdict goes under it. fn headline( layout: card::Layout, context: &Context, verdict: &str, round: i64, ) -> (String, String) { if layout == card::Layout::Daily && let Some(name) = &context.scenario { // The result plate carries the verdict, so the foot of the card is // the same quiet line every other card ends with. return (name.clone(), context.footer_line(round)); } (verdict.to_owned(), context.footer_line(round)) } /// A machine's condition as one short phrase, for a card that has room for a /// line and not a sentence. The page's own `condition` returns an owned /// String; a card borrows, so this is the static half of the same words. fn condition_word(unit: &serde_json::Value) -> &'static str { let status = unit .get("status") .and_then(serde_json::Value::as_str) .unwrap_or("active"); if status != "active" { return match status { "destroyed" => "destroyed", "devastated" => "devastated", "salvage" => "salvage", "retreated" => "withdrew", "ejected" => "ejected", "captured" => "captured", "pushed" => "pushed off", _ => "out of the fight", }; } match unit.get("damage").and_then(serde_json::Value::as_str) { Some("crippled") => "crippled", Some("heavy") => "heavy damage", Some("moderate") => "damaged", Some("light") => "light damage", _ => "unhurt", } } /// The faces the page is set in: the site's own, so a report reads as part of /// the site rather than as a document the API happens to serve. See /// `assets/webfonts/README.md`. const FONTS: [(&str, &[u8]); 4] = [ ( "chakra-petch-600", include_bytes!("../assets/webfonts/chakra-petch-600-latin.woff2"), ), ( "chakra-petch-700", include_bytes!("../assets/webfonts/chakra-petch-700-latin.woff2"), ), ( "barlow-semi-condensed-400", include_bytes!("../assets/webfonts/barlow-semi-condensed-400-latin.woff2"), ), ( "barlow-semi-condensed-600", include_bytes!("../assets/webfonts/barlow-semi-condensed-600-latin.woff2"), ), ]; /// One face. Immutable, so it is cached for a year. pub async fn font(Path(name): Path) -> Response { let name = name.strip_suffix(".woff2").unwrap_or(&name); let Some((_, bytes)) = FONTS.iter().find(|(id, _)| *id == name) else { return StatusCode::NOT_FOUND.into_response(); }; ( [ (header::CONTENT_TYPE, "font/woff2"), (header::CACHE_CONTROL, "public, max-age=31536000, immutable"), ], *bytes, ) .into_response() } /// Inline, because this page is one document and a stylesheet would be a /// second request an unfurler never makes. The palette is the site's. const STYLE: &str = "\ /* The site's own faces and tokens, so a report reads as a page of lance.blue \ rather than as something the API serves. Kept in step by the tests named \ in assets/webfonts/README.md. */\ @font-face{font-family:'Chakra Petch';font-style:normal;font-weight:600;\ font-display:swap;src:url('/reports/fonts/chakra-petch-600.woff2') format('woff2')}\ @font-face{font-family:'Chakra Petch';font-style:normal;font-weight:700;\ font-display:swap;src:url('/reports/fonts/chakra-petch-700.woff2') format('woff2')}\ @font-face{font-family:'Barlow Semi Condensed';font-style:normal;font-weight:400;\ font-display:swap;src:url('/reports/fonts/barlow-semi-condensed-400.woff2') format('woff2')}\ @font-face{font-family:'Barlow Semi Condensed';font-style:normal;font-weight:600;\ font-display:swap;src:url('/reports/fonts/barlow-semi-condensed-600.woff2') format('woff2')}\ /* light-dark() rather than a media query, for the site's own reason: a \ media query cannot see a reader who has told their browser otherwise, and \ a token defined in one branch and not the other keeps its dark value on a \ light page. */\ :root{color-scheme:dark light;\ --bg:light-dark(#f4f6fa,#0b1017);\ --panel:light-dark(#ffffff,#131b26);\ --line:light-dark(#d8dfea,#22303f);\ --ink:light-dark(#15202b,#e6edf5);\ --muted:light-dark(#5a6b80,var(--muted));\ --accent:light-dark(#1f5fd0,#4c8dff);\ --on-accent:light-dark(#ffffff,#0b1017);\ --mark:light-dark(#0f7d92,#4fc1d6);\ --font-display:'Chakra Petch',ui-sans-serif,system-ui,sans-serif;\ --font-text:'Barlow Semi Condensed',ui-sans-serif,system-ui,sans-serif;\ --danger:light-dark(#b3261e,#ff8a7a);\ --warn:light-dark(#8a5a00,#d8a13a);\ --sunk:light-dark(#eef1f7,#0f1621);\ --good:light-dark(#0a7a52,#35d08a);\ --faint:light-dark(#9aa7b8,#3d4a5a);\ --hurt:light-dark(#c2410c,#ffb066);\ --scar:light-dark(#e0b4ae,#5d3a38);\ --radius:6px}\ body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--font-text);\ font-size:1rem;line-height:1.5}\ main{max-width:640px;margin:0 auto;padding:32px 20px 48px}\ h1{margin:0;font-family:var(--font-display);font-weight:700;font-size:1.6rem;\ line-height:1.25;letter-spacing:.01em}\ .round{margin:6px 0 20px;color:var(--muted);font-size:.85rem}\ .forces{list-style:none;margin:20px 0 0;padding:0;display:grid;gap:10px}\ .forces li{padding:12px 14px;border:1px solid var(--line);border-radius:var(--radius);\ background:var(--panel)}\ .who{margin:0;font-family:var(--font-display);font-weight:600}\ .who a{color:var(--ink);text-decoration:none}\ .who a:hover{color:var(--accent);text-decoration:underline}\ .victor{margin-left:6px;padding:1px 6px;border:1px solid var(--mark);border-radius:999px;\ color:var(--mark);font-size:.7rem;text-transform:uppercase;letter-spacing:.1em}\ .stats{margin:6px 0 8px;color:var(--muted);font-size:.8rem}\ .bar{height:4px;border-radius:2px;background:var(--line);overflow:hidden}\ .bar div{height:100%;background:var(--accent)}\ .out{margin-top:24px}\ .out a{color:var(--accent)}\ .outsep{color:var(--muted)}\ .eyebrow{margin:0 0 2px;font-family:var(--font-labels,var(--font-display));\ font-size:.66rem;font-weight:700;letter-spacing:.16em;\ text-transform:uppercase;color:var(--mark)}\ .share{position:relative;margin:18px 0 0}\ /* One slide at a time, chosen by the radio immediately before it. A radio \ rather than `:target`: following a fragment scrolls the gallery to the top \ of the viewport, so the whole page jumps on every press. The radios are \ rendered - kept out of sight rather than out of the box tree - so they \ stay focusable and the group can be turned with the arrow keys, and they \ are fixed so that focusing one can never scroll anything either. */\ .pickr{position:fixed;top:0;left:0;width:1px;height:1px;margin:0;\ opacity:0;pointer-events:none}\ .slide{display:none}\ .pickr:checked+.slide{display:block}\ /* The labels cannot take focus, so the ring belongs to the radio holding it \ and is drawn around the card that radio is showing. */\ .pickr:focus-visible+.slide .framed{outline:2px solid var(--mark);\ outline-offset:3px}\ .framed{position:relative}\ .cardshot{display:block;width:100%;border:1px solid var(--line);border-radius:6px}\ /* Full height of the card, because they are the control this block is for: \ a chevron floating in the middle of a busy picture is something to hunt \ for. The scrim is what makes them legible over any card. */\ .turn{position:absolute;top:0;bottom:0;width:16%;min-width:3.6rem;\ cursor:pointer;user-select:none;-webkit-user-select:none;\ display:flex;align-items:center;justify-content:center;\ color:var(--mark);font-size:2.2rem;line-height:1;text-decoration:none;\ text-shadow:0 2px 12px rgba(0,0,0,.95)}\ .turn.back{left:0;border-radius:6px 0 0 6px;\ background:linear-gradient(90deg,rgba(6,10,15,.92),rgba(6,10,15,.05))}\ .turn.on{right:0;border-radius:0 6px 6px 0;\ background:linear-gradient(270deg,rgba(6,10,15,.92),rgba(6,10,15,.05))}\ .turn:hover{color:var(--bg)}\ /* The glyph is knocked out of a lit column on hover, so the control is \ unmistakably a control rather than a mark on the picture. */\ .turn.back:hover{\ background:linear-gradient(90deg,rgba(79,193,214,.94),rgba(79,193,214,.06))}\ .turn.on:hover{\ background:linear-gradient(270deg,rgba(79,193,214,.94),rgba(79,193,214,.06))}\ .pick{display:flex;align-items:center;gap:8px;margin:8px 0 14px}\ .named{font-size:.72rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase;\ color:var(--muted);margin-right:auto}\ .dot{width:8px;height:8px;border-radius:50%;background:var(--line);display:block;\ cursor:pointer}\ .dot.here{background:var(--mark)}\ .dot:hover{background:var(--accent)}\ .sharerow{display:flex;justify-content:center}\ /* Cut like a plate rather than rounded like a chip - the notch the site puts \ on its own controls, at the top left, so this reads as the same family of \ object as the Play button. */\ .post{--notch:.6rem;display:inline-block;padding:.62rem 1.6rem;background:var(--accent);\ color:var(--on-accent);font-size:.82rem;font-weight:700;letter-spacing:.14em;\ text-transform:uppercase;text-decoration:none;\ clip-path:polygon(var(--notch) 0,100% 0,100% 100%,0 100%,0 var(--notch))}\ .post:hover{background:color-mix(in srgb,var(--accent) 82%,white)}\ /* An outline is clipped away with the rest of the box, so the ring is inset. */\ .post:focus-visible{outline:0;box-shadow:inset 0 0 0 2px var(--on-accent)}\ .perma{flex:1;min-width:14rem;padding:.45rem .6rem;border:1px solid var(--line);\ border-radius:4px;background:var(--sunk);color:var(--muted);font:inherit;font-size:.72rem}\ .cta{display:flex;flex-wrap:wrap;align-items:baseline;gap:10px;margin:22px 0 0;\ padding-top:16px;border-top:1px solid var(--line)}\ .play{--notch:.6rem;display:inline-block;padding:.55rem 1.5rem;background:var(--mark);\ color:var(--on-accent);font-size:.9rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase;\ text-decoration:none;\ clip-path:polygon(var(--notch) 0,100% 0,100% 100%,0 100%,0 var(--notch))}\ .play:hover{background:color-mix(in srgb,var(--mark) 82%,white)}\ .play:focus-visible{outline:0;box-shadow:inset 0 0 0 2px var(--bg)}\ .ctanote{font-size:.72rem;color:var(--muted)}\ .force{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));\ gap:8px;margin-top:10px}\ .utile{display:block;padding:8px;border:1px solid var(--line);border-radius:4px;\ background:var(--sunk);color:var(--ink);text-decoration:none}\ .utile:hover,.utile:focus{border-color:var(--accent);background:var(--panel)}\ .utile.lost,.utile.crippled{border-color:var(--scar)}\ .uicon{display:block;margin:0 auto 4px;width:84px;height:72px;image-rendering:pixelated}\ .utile.lost .uicon{opacity:.45;filter:grayscale(1)}\ .uname{display:block;font-size:.78rem;font-weight:600;overflow:hidden;\ text-overflow:ellipsis;white-space:nowrap}\ .ufig{display:block;font-size:.66rem;color:var(--muted)}\ .ustate{display:block;margin-top:2px;font-size:.62rem;text-transform:uppercase;\ letter-spacing:.08em;color:var(--muted)}\ .utile.whole .ustate{color:var(--good)}\ .utile.light .ustate,.utile.moderate .ustate{color:var(--hurt)}\ .utile.heavy .ustate{color:var(--warn)}\ .utile.crippled .ustate,.utile.lost .ustate{color:var(--danger)}\ .ubar{display:block;height:3px;margin-top:5px;border-radius:2px;background:var(--line)}\ .ubar span{display:block;height:100%;border-radius:2px;background:var(--mark)}\ .modal{display:none}\ .modal:target{display:block;position:fixed;inset:0;z-index:20;overflow-y:auto;\ padding:24px 12px;background:rgba(4,8,13,.82)}\ .mbox{position:relative;z-index:2;max-width:820px;margin:0 auto;padding:16px;\ border:1px solid var(--line);border-radius:6px;background:var(--panel)}\ .mback{position:fixed;inset:0;z-index:1;display:block}\ .mbox h2{margin:0;font-size:1.05rem}\ .mbox h3{margin:14px 0 6px;font-size:.7rem;text-transform:uppercase;\ letter-spacing:.1em;color:var(--muted)}\ .mfacts{margin:4px 0 0;font-size:.75rem;color:var(--muted)}\ .mclose{position:absolute;top:8px;right:12px;color:var(--muted);font-size:1.3rem;\ line-height:1;text-decoration:none}\ .mclose:hover{color:var(--ink)}\ .sheet{display:grid;gap:5px;margin-top:10px;align-items:start}\ .sheet.mek{grid-template-columns:repeat(5,1fr);\ grid-template-areas:\". . hd . .\" \"la lt ct rt ra\" \". ll . rl .\"}\ .sheet.veh{grid-template-columns:repeat(3,1fr);max-width:430px;margin-inline:auto;\ grid-template-areas:\". fr .\" \"ls bd rs\" \". tu .\" \". ft .\" \". rr .\"}\ .loc{padding:5px;border:1px solid var(--line);border-radius:3px;background:var(--sunk)}\ .loc.gone{border-color:var(--scar)}\ .loc.off{border-style:dashed;border-color:var(--danger)}\ .labbr{margin:0 0 3px;font-size:.62rem;font-weight:700;letter-spacing:.08em}\ .lstate{margin-left:5px;font-weight:400;color:var(--danger)}\ .meter{display:flex;align-items:center;gap:4px;margin-top:3px;font-size:.6rem;\ line-height:1.4;color:var(--muted)}\ .mlabel{width:.8em;flex:none}\ .mtrack{flex:1;height:3px;border-radius:2px;background:var(--line);overflow:hidden}\ .mtrack span{display:block;height:100%;background:var(--mark)}\ .structure .mtrack span{background:var(--muted)}\ .structure.hurt .mtrack span{background:var(--danger)}\ .mfig{flex:none;font-variant-numeric:tabular-nums}\ .slots{display:flex;flex-wrap:wrap;gap:2px;margin-top:4px}\ .s{width:6px;height:6px;border-radius:1px;background:var(--muted)}\ .s.empty{background:transparent;box-shadow:inset 0 0 0 1px var(--line)}\ .s.hit,.s.breached{background:var(--warn)}\ .s.destroyed{background:var(--danger)}\ .s.missing{background:transparent;box-shadow:inset 0 0 0 1px var(--danger)}\ .tally{margin:3px 0 0;font-size:.58rem;color:var(--muted);font-variant-numeric:tabular-nums}\ .crit{margin:3px 0 0;font-size:.58rem;line-height:1.35;color:var(--danger)}\ .sheet.flow{grid-template-columns:repeat(auto-fill,minmax(120px,1fr));grid-template-areas:none}\ .sheet.flow .loc{grid-area:auto}\ .note{margin:10px 0 0;padding:7px 9px;border-left:3px solid var(--warn);background:var(--sunk);\ font-size:.72rem;color:var(--muted)}\ .critlist{list-style:none;margin:4px 0 0;padding:0;border-top:1px solid var(--line)}\ .critlist li{display:flex;gap:4px;padding:0 1px;font-size:.52rem;line-height:1.45;\ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\ .critlist .n{width:1.1em;flex:none;text-align:right;color:var(--faint);\ font-variant-numeric:tabular-nums}\ .critlist .empty{color:var(--faint)}\ .critlist .hit,.critlist .breached{color:var(--warn)}\ .critlist .destroyed,.critlist .missing{color:var(--danger);text-decoration:line-through}\ .equip{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:3px;\ background:var(--sunk);overflow:hidden}\ .equip li{display:flex;align-items:baseline;gap:8px;padding:2px 8px;font-size:.68rem;\ border-bottom:1px solid rgba(34,48,63,.6)}\ .equip li:last-child{border-bottom:0}\ .equip .ename{flex:1}\ .equip .eloc{width:2.4em;color:var(--muted);font-size:.62rem;text-transform:uppercase;\ letter-spacing:.06em}\ .equip .eshots{color:var(--muted);font-variant-numeric:tabular-nums}\ .equip .ecount{color:var(--muted)}\ .equip .hit{color:var(--warn)}\ .equip .destroyed{color:var(--danger);text-decoration:line-through}\ .vdamage{list-style:none;margin:0;padding:0;display:grid;gap:2px;font-size:.68rem}\ .vdamage .ok{color:var(--muted)}\ .vdamage .destroyed{color:var(--danger)}\ .troopers{margin:0 0 4px;font-size:.72rem;color:var(--muted)}\ .ammo{list-style:none;margin:0;padding:0;display:grid;gap:3px}\ .ammo li{display:flex;align-items:baseline;gap:6px;font-size:.68rem;color:var(--muted)}\ .aname{flex:1;color:var(--ink)}\ .ashots{font-variant-numeric:tabular-nums}\ .gone{color:var(--danger)}\ @media (max-width:560px){.sheet{grid-template-columns:repeat(2,1fr);grid-template-areas:none}\ .loc{grid-area:auto}}\ "; #[cfg(test)] mod tests { /// A side that lost every machine is flagged `observer` by MegaMek, and /// arena records each seat as the game last described it. Dropping it took /// the loser out of a decided match, which then read as a draw with one /// force in it - match 06e4fd45, where the OPFOR seat held three Meks and /// 3,142 battle value and did not appear on the report at all. #[test] fn a_wiped_out_side_is_the_loser_not_a_watcher() { let result = serde_json::json!({ "round": 5, "victoryTeam": 1, "victoryPlayerId": -1, "players": [ {"id": 0, "name": "@permadeath.com", "slot": "Challenger", "observer": false, "team": 1, "unitsStart": 1, "unitsRemaining": 1, "bvStart": 3132, "bvRemaining": 2766, "units": []}, {"id": 1, "name": "@lance.blue", "slot": "OPFOR", "observer": true, "team": 2, "unitsStart": 3, "unitsRemaining": 0, "bvStart": 3142, "bvRemaining": 0, "units": []}, {"id": 3, "name": "ArenaObserver", "slot": "ArenaObserver", "observer": true, "team": -1, "unitsStart": 0, "unitsRemaining": 0, "bvStart": 0, "bvRemaining": 0, "units": []} ] }); let summary = summarise(&result).expect("a summary"); let names: Vec<&str> = summary.forces.iter().map(|f| f.name.as_str()).collect(); assert_eq!( names, vec!["@permadeath.com", "@lance.blue"], "the watcher goes, both fighters stay" ); assert_eq!(summary.title(), "@permadeath.com beat @lance.blue"); } use super::*; /// One real machine, as arena's UnitReport wrote it: a Griffin with its /// centre torso stripped to structure, its left arm blown off, its engine /// hit and a wounded pilot. Captured from the container rather than /// invented, so a change in the document's shape shows up here. fn damaged_griffin() -> serde_json::Value { serde_json::from_str(GRIFFIN).expect("the captured unit parses") } const GRIFFIN: &str = r#"{"id": 0, "name": "Griffin GRF-1N", "chassis": "Griffin", "model": "GRF-1N", "kind": "mek", "tons": 55.0, "bv": 1755, "status": "active", "armorPercent": 78, "damage": "heavy", "crippled": false, "heat": 0, "heatCapacity": 12, "crew": {"name": "Trainee A2", "gunnery": 4, "piloting": 0, "hits": 2, "dead": false, "ejected": false}, "ammo": [{"name": "LRM 10 Ammo", "loc": "RT", "shots": 12, "shotsMax": 12, "destroyed": false}, {"name": "LRM 10 Ammo", "loc": "RT", "shots": 12, "shotsMax": 12, "destroyed": false}], "equipment": [{"name": "PPC", "loc": "RA", "hit": false, "destroyed": false}, {"name": "Jump Jet", "loc": "LT", "hit": false, "destroyed": false}, {"name": "Jump Jet", "loc": "LT", "hit": false, "destroyed": false}, {"name": "Jump Jet", "loc": "RT", "hit": false, "destroyed": false}, {"name": "Jump Jet", "loc": "RT", "hit": false, "destroyed": false}, {"name": "LRM 10", "loc": "RT", "hit": false, "destroyed": false}, {"name": "LRM 10 Ammo", "loc": "RT", "hit": false, "destroyed": false, "shots": 12, "shotsMax": 12}, {"name": "LRM 10 Ammo", "loc": "RT", "hit": false, "destroyed": false, "shots": 12, "shotsMax": 12}, {"name": "Heat Sink", "loc": "CT", "hit": false, "destroyed": false}, {"name": "Jump Jet", "loc": "CT", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}, {"name": "Heat Sink", "loc": "", "hit": false, "destroyed": false}], "vehicle": null, "infantry": null, "locations": [{"abbr": "HD", "name": "Head", "armor": 9, "armorMax": 9, "internal": 3, "internalMax": 3, "destroyed": false, "blownOff": false, "slots": [{"name": "Life Support", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Sensors", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Standard Cockpit", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, {"name": "Sensors", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Life Support", "hit": false, "destroyed": false, "breached": false, "missing": false}]}, {"abbr": "CT", "name": "Center Torso", "armor": 0, "armorMax": 20, "internal": 3, "internalMax": 18, "destroyed": false, "blownOff": false, "rear": 7, "rearMax": 7, "slots": [{"name": "Engine", "hit": true, "destroyed": true, "breached": false, "missing": false}, {"name": "Engine", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Engine", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Standard Gyro", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Standard Gyro", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Standard Gyro", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Standard Gyro", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Engine", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Engine", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Engine", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Heat Sink", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Jump Jet", "hit": false, "destroyed": false, "breached": false, "missing": false}]}, {"abbr": "RT", "name": "Right Torso", "armor": 20, "armorMax": 20, "internal": 13, "internalMax": 13, "destroyed": false, "blownOff": false, "rear": 6, "rearMax": 6, "slots": [{"name": "Jump Jet", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Jump Jet", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "LRM 10", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "LRM 10", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "LRM 10 Ammo", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "LRM 10 Ammo", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null, null, null, null, null]}, {"abbr": "LT", "name": "Left Torso", "armor": 20, "armorMax": 20, "internal": 13, "internalMax": 13, "destroyed": false, "blownOff": false, "rear": 6, "rearMax": 6, "slots": [{"name": "Jump Jet", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Jump Jet", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null, null, null, null, null, null, null, null, null]}, {"abbr": "RA", "name": "Right Arm", "armor": 14, "armorMax": 14, "internal": 9, "internalMax": 9, "destroyed": false, "blownOff": false, "slots": [{"name": "Shoulder", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Upper Arm", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Lower Arm", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Hand", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "PPC", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "PPC", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "PPC", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null, null, null, null]}, {"abbr": "LA", "name": "Left Arm", "armor": 0, "armorMax": 14, "internal": 0, "internalMax": 9, "destroyed": true, "blownOff": true, "slots": [{"name": "Shoulder", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Upper Arm", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Lower Arm", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Hand", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null, null, null, null, null, null, null]}, {"abbr": "RL", "name": "Right Leg", "armor": 18, "armorMax": 18, "internal": 13, "internalMax": 13, "destroyed": false, "blownOff": false, "slots": [{"name": "Hip", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Upper Leg", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Lower Leg", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Foot", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null]}, {"abbr": "LL", "name": "Left Leg", "armor": 18, "armorMax": 18, "internal": 13, "internalMax": 13, "destroyed": false, "blownOff": false, "slots": [{"name": "Hip", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Upper Leg", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Lower Leg", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "Foot", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null]}]}"#; /// A real vehicle, captured the same way: five locations, twenty-five /// slots each with four of them filled, and MegaMek's own vehicle /// criticals. It is the case a Mek-shaped sheet gets wrong. fn packrat() -> serde_json::Value { serde_json::from_str(PACKRAT).expect("the captured vehicle parses") } const PACKRAT: &str = r#"{"id": 11, "name": "Packrat LRPV PKR-T5", "chassis": "Packrat LRPV", "model": "PKR-T5", "kind": "vehicle", "tons": 20.0, "bv": 248, "status": "active", "armorPercent": 100, "damage": "none", "crippled": false, "heat": 0, "heatCapacity": 999, "crew": {"name": "Ernest (Patch) McGuire", "gunnery": 7, "piloting": 7, "hits": 0, "dead": false, "ejected": false}, "ammo": [{"name": "SRM 6 Ammo", "loc": "BD", "shots": 15, "shotsMax": 15, "destroyed": false}, {"name": "SRM 6 Ammo", "loc": "BD", "shots": 15, "shotsMax": 15, "destroyed": false}], "equipment": [{"name": "SRM 6", "loc": "FR", "hit": false, "destroyed": false}, {"name": "Flamer", "loc": "RR", "hit": false, "destroyed": false}, {"name": "SRM 6 Ammo", "loc": "BD", "hit": false, "destroyed": false, "shots": 15, "shotsMax": 15}, {"name": "SRM 6 Ammo", "loc": "BD", "hit": false, "destroyed": false, "shots": 15, "shotsMax": 15}], "vehicle": {"turretLocked": false, "motiveDamage": 0, "driverHit": false, "commanderHit": false, "stabilisers": []}, "infantry": null, "locations": [{"abbr": "BD", "name": "Body", "armor": 0, "armorMax": 0, "internal": 0, "internalMax": 0, "destroyed": false, "blownOff": false, "slots": [{"name": "SRM 6 Ammo", "hit": false, "destroyed": false, "breached": false, "missing": false}, {"name": "SRM 6 Ammo", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null]}, {"abbr": "FR", "name": "Front", "armor": 16, "armorMax": 16, "internal": 2, "internalMax": 2, "destroyed": false, "blownOff": false, "slots": [{"name": "SRM 6", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null]}, {"abbr": "RS", "name": "Right", "armor": 16, "armorMax": 16, "internal": 2, "internalMax": 2, "destroyed": false, "blownOff": false, "slots": [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null]}, {"abbr": "LS", "name": "Left", "armor": 16, "armorMax": 16, "internal": 2, "internalMax": 2, "destroyed": false, "blownOff": false, "slots": [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null]}, {"abbr": "RR", "name": "Rear", "armor": 16, "armorMax": 16, "internal": 2, "internalMax": 2, "destroyed": false, "blownOff": false, "slots": [{"name": "Flamer", "hit": false, "destroyed": false, "breached": false, "missing": false}, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null]}]}"#; /// A match reached the way most are: a scenario against Princess, played /// on a day. The daily and duel headings are the same code with a /// different row, and `entry_reads_the_row` covers those. fn fixture_context() -> Context { Context::read( &crate::db::MatchRow { id: "m1".into(), owner_did: "did:plc:a".into(), status: "over".into(), token: "t".into(), task_arn: None, task_ip: None, // 2026-08-19, which is what the page should print. created_at: 1_787_097_600, scenario: Some("data/scenarios/TrainingScenarios/1-FirstRun.mms".into()), // Eleven minutes from playable to decided. started_at: Some(1_787_097_600), ended_at: Some(1_787_098_260), }, &[], ) } /// The page as a bare link gets it: no point of view, the first card. fn rendered(summary: &Summary) -> String { let context = fixture_context(); let offered = layouts(&context, summary, false, false); document( summary, &context, &Page { here: "https://lance.blue/reports/m1", base: "https://lance.blue/reports/m1", image: "https://lance.blue/reports/m1/card.png", home: "https://lance.blue", perspective: None, offered: &offered, chosen: offered[0], participant: true, units: None, }, ) } /// The same match, reached as the day's challenge. fn daily_context() -> Context { Context::read( &crate::db::MatchRow { id: "m1".into(), owner_did: "did:plc:a".into(), status: "over".into(), token: "t".into(), task_arn: None, task_ip: None, created_at: 1_787_097_600, scenario: Some("daily:bug-company".into()), // Eleven minutes from playable to decided. started_at: Some(1_787_097_600), ended_at: Some(1_787_098_260), }, &[], ) } /// A machine the index knows is drawn as itself; one it does not gets /// MegaMek's own default for its class, addressed the same way. Both /// halves matter: the index is Meks only, so every vehicle and platoon /// depends on the fallback, and neither half is a file in this repo. #[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": "vehicle", "tons": 80.0, "status": "active" }); let tile = unit_tile("u0-1", &demolisher, Some(&index)); assert!( tile.contains( "https://lance.blue/assets/megamek/0.51.0/data/images/units/\ defaults/default_tracked.png" .replace(['\n', ' '], "") .as_str() ), "MegaMek's default for a ground vehicle: {tile}" ); // Nothing under /reports/ any more: the pictures are the release's. assert!(!tile.contains("/reports/sprites/"), "{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)), ); } /// Whatever was fetched for a machine is what the card paints, and a /// machine nothing came down for is painted with nothing rather than /// with a stand-in. Checked on this side of the split because the page /// and the card reach the same picture 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(crate::card::flat_camo([10, 200, 90])), ); 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 .as_ref() .map(|bytes| bytes.to_vec()) }; assert_eq!( machine(&art).as_deref(), Some(crate::card::flat_camo([10, 200, 90]).as_slice()), "the fetched art did not reach the card" ); assert_eq!( machine(&no_art()), None, "a machine nothing came down for was painted with something" ); } /// No index at all - development, or a fetch that failed - draws the /// machine without a picture rather than with a broken one. /// /// The release the art is published under is the index's own, so with no /// index there is no address to build. That is the cost of holding no /// copies here, and it is why `units::load` says so at startup: the tile /// still carries the name, the tonnage and the condition, which is what /// a reader is here for. #[test] fn no_index_draws_no_picture() { 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(" serde_json::Value { serde_json::json!({ "round": 7, "victoryTeam": 1, "victoryPlayerId": -1, "players": [ {"id": 1, "name": "@a.example", "slot": "Trainee", "observer": false, "team": 1, "unitsRemaining": 1, "unitsStart": 1, "bvRemaining": 1639, "bvStart": 1842}, {"id": 2, "name": "@lance.blue", "slot": "OPFOR", "observer": false, "team": 2, "unitsRemaining": 0, "unitsStart": 1, "bvRemaining": 0, "bvStart": 1817}, {"id": 3, "name": "ArenaObserver", "slot": "ArenaObserver", "observer": false, "team": 0, "unitsRemaining": 0, "unitsStart": 0, "bvRemaining": 0, "bvStart": 0} ], "identity": {"players": [ {"slot": "Trainee", "control": "human", "did": "did:plc:abc", "handle": "a.example"}, {"slot": "OPFOR", "control": "bot", "did": null, "handle": "lance.blue"} ]} }) } /// The watcher is in every result document and is not a player. It reached /// the match report once; nothing that renders a result may show it. #[test] fn the_watcher_is_not_a_force() { let summary = summarise(&result()).expect("a summary"); assert_eq!(summary.forces.len(), 2); assert!(summary.forces.iter().all(|f| f.name != "ArenaObserver")); } /// Recorded, never inferred: the loser here still has units on the board /// in a forced victory, so anything counting them would name them winner. #[test] fn the_winner_comes_from_the_document() { let summary = summarise(&result()).expect("a summary"); assert_eq!(summary.title(), "@a.example beat @lance.blue"); } #[test] fn a_draw_says_so() { let mut doc = result(); doc["victoryTeam"] = 0.into(); let summary = summarise(&doc).expect("a summary"); assert_eq!(summary.title(), "@a.example vs @lance.blue - a drawn match"); } /// The opponent is named and never linked. A public page is not a place /// to put somebody else's account in front of an audience they did not /// choose - see the consent rule in plan/post-to-bluesky.md. #[test] fn no_handle_is_a_link() { let html = page(); assert!(html.contains("@lance.blue")); assert!(!html.contains("bsky.app/profile")); } /// The report is where a player lands when a match ends, and the survey /// link at the foot of it is the one this alpha asks from. It is a /// constant three bundles carry a copy of, so a typo here is a dead link /// nothing else would catch. #[test] fn the_report_links_the_survey() { let html = page(); assert!(html.contains(SURVEY_URL)); assert!(html.contains(SURVEY_LABEL)); } /// Handles are chosen by whoever holds them and arrive in a document /// written inside a match container. Nothing interpolated into the page /// may close a tag. #[test] fn names_are_escaped() { assert_eq!( escape(""), "<script>alert("x")</script>" ); } fn page() -> String { let mut doc = result(); // The preview and the tag assertions read better against a page with // machines on it, which is what a finished match now produces. Four // of them, in four states, because the force card is a grid and a // grid of one says nothing about the grid. let mut wreck = damaged_griffin(); wreck["name"] = "Locust LCT-1V".into(); wreck["tons"] = 20.0.into(); wreck["status"] = "destroyed".into(); wreck["damage"] = "crippled".into(); wreck["armorPercent"] = 0.into(); let mut fresh = damaged_griffin(); fresh["name"] = "Atlas AS7-D".into(); fresh["tons"] = 100.0.into(); fresh["damage"] = "none".into(); fresh["armorPercent"] = 100.into(); doc["players"][0]["units"] = serde_json::json!([damaged_griffin(), fresh, packrat(), wreck]); let summary = summarise(&doc).expect("a summary"); rendered(&summary) } /// A vehicle is not a Mek, and its sheet says so: the armour diagram it /// does have, MegaMek's own vehicle criticals, and its equipment. No /// column of twenty-five numbered slots per location, four of which hold /// anything. #[test] fn a_vehicle_gets_the_vehicle_sheet() { let mut doc = result(); doc["players"][0]["units"] = serde_json::json!([packrat()]); let summary = summarise(&doc).expect("a summary"); let html = rendered(&summary); assert!( html.contains("Vehicle damage"), "the vehicle's own criticals" ); assert!(html.contains("Weapons and equipment"), "what it carried"); assert!(!html.contains("Critical hits"), "no Mek crit columns"); assert!(!html.contains("Roll again"), "no empty Mek slots"); } /// A class nobody has drawn a sheet for says so, rather than being poured /// into a Mek's body plan. #[test] fn an_unknown_class_says_it_has_no_sheet() { let mut unit = packrat(); unit["kind"] = "aerospace".into(); let mut doc = result(); doc["players"][0]["units"] = serde_json::json!([unit]); let summary = summarise(&doc).expect("a summary"); let html = rendered(&summary); assert!(html.contains("No record sheet for this class")); assert!(html.contains("Weapons and equipment")); } /// The machines, and the damage on them. A page that lists a force's /// units without saying what happened to them is the summary again. #[test] fn the_page_draws_the_record_sheet() { let mut doc = result(); doc["players"][0]["units"] = serde_json::json!([damaged_griffin()]); let summary = summarise(&doc).expect("a summary"); let html = rendered(&summary); assert!(html.contains("Griffin GRF-1N")); // The stripped torso, the blown-off arm and the engine, each read // back out of the page rather than out of the document. assert!(html.contains("0/20"), "the stripped torso"); assert!(html.contains("blown off"), "the arm"); // The classic sheet: every slot numbered, in the box of the location // it is in, with the empties saying what the paper sheet says. assert!( html.contains("
      "), "slots in their boxes" ); assert!(html.contains("Roll again"), "empty slots keep their line"); assert!(!html.contains("of 12 slots"), "no tally beside the armour"); assert!( html.contains("class=\"destroyed\">"), "a struck slot" ); assert!( html.contains("Weapons and equipment"), "everything it carries" ); assert!(html.contains("2 hits"), "the pilot"); // MegaMek's own reading of the machine, which is the one thing here a // reader does not have to know record sheets to use. assert!( html.contains("heavy damage") || html.contains("crippled") || html.contains("damaged") || html.contains("unhurt"), "the condition" ); // 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 no release to address a picture under - // the tile is the machine's name and its condition, and nothing is // served from this repo for it any more. assert!( !html.contains("/reports/sprites/"), "a silhouette this service ships" ); assert!( html.contains("class=\"modal\" id=\"u0-0\""), "the sheet's panel" ); assert!(html.contains("href=\"#u0-0\""), "the way into it"); } /// A location's box carries its own slots, numbered, with the empties in /// place: which line went is what a record sheet is for, and the tally /// this replaced could only say how many. #[test] fn a_location_box_carries_its_slots() { let unit = damaged_griffin(); let torso = unit["locations"] .as_array() .expect("locations") .iter() .find(|location| location["abbr"] == "CT") .expect("a centre torso"); let column = crit_column(torso); assert!(column.contains("
        ")); // Twelve lines, whatever is in them. assert_eq!(column.matches(">>| { card::draw(&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: &art, }, )) .expect("the card draws") }; let plain = painted(summary.forces.iter().map(|_| None).collect()); let red = painted( summary .forces .iter() .enumerate() .map(|(i, _)| if i == 0 { Some(camo.clone()) } else { None }) .collect(), ); assert_ne!(plain, red, "the camo changed nothing"); } /// Machine `n`'s picture belongs in machine `n`'s column. /// /// The lance card writes a name under each machine, and it used to draw /// the pictures on one grid and the names on another - packed at 150 /// pixels against a name column of 272 - so from the second machine on, a /// picture sat under somebody else's name, 366 pixels out by the fourth. /// /// Checked by moving one machine at a time: swap machine `n`'s art for /// something that draws differently, and every pixel that changes has to /// fall inside the column its name is written in. That says the picture /// and the word line up without repeating the arithmetic that decides /// where either of them goes - which is the mistake being guarded here. #[test] fn each_machine_is_drawn_in_its_own_column() { let machine = |name: &str, tons: f64| { let mut unit = damaged_griffin(); unit["name"] = name.into(); unit["tons"] = tons.into(); unit["kind"] = "mek".into(); unit["status"] = "active".into(); unit }; // Four, which is the most the card names and so the worst case. let names = [ "Atlas AS7-D", "Griffin GRF-1N", "Warhammer WHM-6R", "Locust LCT-1V", ]; let mut doc = result(); doc["players"][0]["units"] = serde_json::Value::Array(names.iter().map(|n| machine(n, 55.0)).collect()); let summary = summarise(&doc).expect("a summary"); let camo: Vec>> = summary.forces.iter().map(|_| None).collect(); // The lance card is one force from its own player's point of view. let perspective = Perspective::read(Some("a.example")).expect("a point of view"); let render = |art: &std::collections::HashMap>>| { let bytes = card::draw(&compose( &summary, Drawn { layout: card::Layout::Lance, brief: &Brief::default(), eyebrow: "SCENARIO: FIRST RUN", verdict: "VICTORY", title: "@a.example won", sub: "First Run", perspective: Some(&perspective), camo: &camo, board: None, art, }, )) .expect("the card draws"); let decoder = png::Decoder::new(std::io::Cursor::new(bytes)); let mut reader = decoder.read_info().expect("the card is a png"); let mut buf = vec![0; reader.output_buffer_size().expect("a sized frame")]; let info = reader.next_frame(&mut buf).expect("a frame"); let rgba = crate::png_util::to_rgba( &buf[..info.buffer_size()], info.color_type, info.width, info.height, ) .expect("rgba"); (info.width as usize, rgba) }; let (width, plain) = render(&no_art()); // Every name column, by the card's own rule: the drawing width split // evenly between the machines. let column = crate::card::lance_column(names.len()); for (index, name) in names.iter().enumerate() { // A silhouette that is not the one this machine would pick, so // the pixels that move are this machine's and nothing else's. let mut art = std::collections::HashMap::new(); art.insert( (*name).to_owned(), std::sync::Arc::new(crate::card::flat_camo([10, 200, 90])), ); let (_, moved) = render(&art); assert_ne!(plain, moved, "swapping {name}'s art changed nothing"); let lo = crate::card::lance_column_x(index, names.len()); let hi = lo + column; let stray: Vec = plain .chunks_exact(4) .zip(moved.chunks_exact(4)) .enumerate() .filter(|(_, (a, b))| a != b) .map(|(offset, _)| (offset % width) as i32) .filter(|x| *x < lo || *x >= hi) .collect(); assert!( stray.is_empty(), "{name} is machine {index}, whose name is written in [{lo}, {hi}), but changing its picture moved {} pixels outside that column (x from {:?} to {:?})", stray.len(), stray.iter().min(), stray.iter().max(), ); } } /// Every card, for all three shapes a match comes in, written out as /// PNGs to look at. Ignored like `preview`: this is for eyes. /// /// cargo test -p headquarters-api share::tests::cards -- --ignored #[test] #[ignore = "writes the cards to /tmp for eyeballing"] fn cards() { // A machine's silhouette follows its tonnage, so the shapes differ. let machine = |name: &str, tons: f64, kind: &str, dead: bool| { let mut unit = damaged_griffin(); unit["name"] = name.into(); unit["tons"] = tons.into(); unit["kind"] = kind.into(); unit["status"] = if dead { "destroyed" } else { "active" }.into(); unit }; let force = |name: &str, slot: &str, team: i64, left: i64, start: i64, bv: i64, units: Vec| { serde_json::json!({ "id": 1, "name": name, "slot": slot, "observer": false, "team": team, "unitsRemaining": left, "unitsStart": start, "bvRemaining": bv, "bvStart": start * 2000, "units": units, }) }; let lance = |dead: usize| { let all = vec![ machine("Atlas AS7-D", 100.0, "mek", dead > 3), machine("Griffin GRF-1N", 55.0, "mek", dead > 2), machine("Vedette Medium Tank", 50.0, "vehicle", dead > 1), machine("Locust LCT-1V", 20.0, "mek", dead > 0), ]; all }; let company = || { let mut all = Vec::new(); for index in 0..12 { all.push(machine( ["Locust LCT-1V", "Wasp WSP-1A", "Stinger STG-3R"][index % 3], 20.0, "mek", index % 3 == 0, )); } all }; let cases: Vec<(&str, serde_json::Value, Option<&str>)> = vec![ // One player against Princess, which is what a daily challenge is. ( "1vbot", serde_json::json!({ "round": 7, "victoryTeam": 1, "victoryPlayerId": -1, "identity": {"players": [ {"slot": "TraineeA", "control": "human"}, {"slot": "OPFOR", "control": "bot"}, ]}, "players": [ force("@permadeath.com", "TraineeA", 1, 3, 4, 1420, lance(1)), force("@lance.blue", "OPFOR", 2, 0, 4, 0, lance(4)), ], }), Some("permadeath.com"), ), // Bug Company by its own faction names, so the daily card places // each force in the zone the scenario deploys it to. ( "daily", serde_json::json!({ "round": 9, "victoryTeam": 1, "victoryPlayerId": -1, "identity": {"players": [ {"slot": "Challenger", "control": "human"}, {"slot": "Talon Lance", "control": "bot"}, {"slot": "Hammer Lance", "control": "bot"}, {"slot": "Anvil Lance", "control": "bot"}, ]}, "players": [ force("@permadeath.com", "Challenger", 1, 1, 1, 3140, lance(0)), force("@lance.blue", "Talon Lance", 2, 1, 4, 420, lance(3)), force("@lance.blue", "Hammer Lance", 2, 0, 4, 0, lance(4)), force("@lance.blue", "Anvil Lance", 2, 2, 4, 780, lance(2)), ], }), Some("permadeath.com"), ), // Twelve machines against four: Bug Company, and the case a row // of four icons cannot draw. ( "company", serde_json::json!({ "round": 12, "victoryTeam": 1, "victoryPlayerId": -1, "identity": {"players": [ {"slot": "TraineeA", "control": "human"}, {"slot": "OPFOR", "control": "bot"}, ]}, "players": [ force("@permadeath.com", "TraineeA", 1, 3, 4, 1180, lance(1)), force("@lance.blue", "OPFOR", 2, 4, 12, 640, company()), ], }), Some("permadeath.com"), ), // Four corners, eight seats: the most a card can hold and still // name anybody. ( "4teams", serde_json::json!({ "round": 14, "victoryTeam": 3, "victoryPlayerId": -1, "players": [ force("@permadeath.com", "A1", 1, 1, 4, 300, lance(3)), force("@ally.example", "A2", 1, 0, 4, 0, lance(4)), force("@c.example", "B1", 2, 2, 4, 900, lance(2)), force("@d.example", "B2", 2, 1, 4, 420, lance(3)), force("@e.example", "C1", 3, 4, 4, 1900, lance(0)), force("@f.example", "C2", 3, 3, 4, 1500, lance(1)), force("@g.example", "D1", 4, 0, 4, 0, lance(4)), force("@h.example", "D2", 4, 1, 4, 260, lance(3)), ], }), Some("permadeath.com"), ), // Handles are domain names, and some people own long ones. This // is the case that finds a card which only shrinks and never // trims. ( "longnames", serde_json::json!({ "round": 6, "victoryTeam": 1, "victoryPlayerId": -1, "identity": {"players": [ {"slot": "TraineeA", "control": "human"}, {"slot": "OPFOR", "control": "bot"}, ]}, "players": [ force( "@extraordinarily-long-handle.solaris-seven.example.com", "TraineeA", 1, 3, 4, 1500, vec![ machine("Marauder II MAD-4A Bounty Hunter", 100.0, "mek", false), machine("Warhammer WHM-6R Black Widow", 70.0, "mek", false), machine("Shadow Hawk SHD-2H Custom", 55.0, "mek", true), machine("Pegasus Scout Hover Tank PGS-3D", 35.0, "vehicle", false), ], ), force("@lance.blue", "OPFOR", 2, 0, 4, 0, lance(4)), ], }), Some("extraordinarily-long-handle.solaris-seven.example.com"), ), // Two people. ( "1v1", serde_json::json!({ "round": 11, "victoryTeam": 2, "victoryPlayerId": -1, "players": [ force("@permadeath.com", "TraineeA", 1, 1, 4, 480, lance(3)), force("@a.example", "TraineeB", 2, 3, 4, 1610, lance(1)), ], }), Some("permadeath.com"), ), // Four, two a side: the case "@a beat @b" cannot say at all. ( "2v2", serde_json::json!({ "round": 9, "victoryTeam": 1, "victoryPlayerId": -1, "players": [ force("@permadeath.com", "TraineeA", 1, 2, 4, 1180, lance(2)), force("@ally.example", "TraineeB", 1, 4, 4, 1890, lance(0)), force("@a.example", "TraineeC", 2, 0, 4, 0, lance(4)), force("@b.example", "TraineeD", 2, 1, 4, 320, lance(3)), ], }), Some("permadeath.com"), ), ]; for (shape, result, who) in cases { let summary = summarise(&result).expect("a summary"); let perspective = Perspective::read(who); let verdict = verdict_line(&summary, perspective.as_ref()); // The context is per layout, below: a daily card is drawn against // a daily's own. for layout in [ card::Layout::Dossier, 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 { continue; } // A camo on the first force when CARD_CAMO names one, so the // painting path can be looked at as well as asserted on: // CARD_CAMO=/path/to/camo.png cargo test ... -- --ignored 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() .enumerate() .map(|(index, _)| if index == 0 { sample.clone() } else { None }) .collect(); // A daily card is drawn against a daily's own context, which // is where its eyebrow, its name and its date come from. let context = if layout == card::Layout::Daily { daily_context() } else { fixture_context() }; let (title, sub) = headline(layout, &context, &verdict, summary.round); let designation = context.designation(layout); let brief = context.brief(); let drawn = card::draw(&compose( &summary, Drawn { layout, brief: &brief, eyebrow: &designation, verdict: verdict_word(&summary, perspective.as_ref()), title: &title, sub: &sub, perspective: perspective.as_ref(), camo: &camo, board: ground.clone(), art: &local_art(&summary), }, )) .expect("the card draws"); let path = format!("/tmp/card-{shape}-{}.png", layout.slug()); std::fs::write(&path, drawn).expect("written"); println!("wrote {path}"); } } } /// The daily card is the front page's card: it reads the challenge's own /// description and each faction's deploy zone out of the scenario file, /// through the same crate the site's index is built with. #[test] fn a_daily_reads_its_own_scenario() { let context = daily_context(); let brief = context.brief(); assert!( brief.blurb.contains("pirate lances"), "the challenge's own description: {}", brief.blurb ); // The zones the front page stands each force in. assert_eq!(brief.zone_of("Challenger").as_deref(), Some("CTR")); assert_eq!(brief.zone_of("Talon Lance").as_deref(), Some("N")); assert_eq!(brief.zone_of("Hammer Lance").as_deref(), Some("SW")); assert_eq!(brief.zone_of("Anvil Lance").as_deref(), Some("SE")); // A scenario this service does not carry has none of it, and the card // draws without. assert!(fixture_context().brief().blurb.is_empty()); } /// The tags an unfurler reads, and the one that decides whether a post /// carries a picture at all. #[test] fn the_page_carries_a_card() { let html = page(); assert!(html.contains( "" )); assert!(html.contains("")); assert!( html.contains("") ); assert!( html.contains("") ); } /// Prints the page, for looking at it. Ignored by default: /// /// cargo test -p headquarters-api share -- --ignored --nocapture preview #[test] #[ignore = "prints the share page for eyeballing"] fn preview() { println!("{}", page()); } /// A match that produced no forces has nothing to show. The page answers /// 404 rather than an empty card with a title over it. #[test] fn an_empty_result_is_no_page() { let doc = serde_json::json!({"round": 1, "players": []}); assert!(summarise(&doc).is_none()); } const ALL_MISSING: [Missing; 4] = [ Missing::Unknown, Missing::Unfinished, Missing::Resultless, Missing::Unreadable, ]; /// A lookup that failed says nothing about the match, so it must not be /// answered - or cached - as an absent one. #[test] fn a_failed_lookup_is_not_an_absent_match() { assert!(Missing::Unreadable.may_yet()); assert_eq!( Missing::Unreadable.status(), StatusCode::SERVICE_UNAVAILABLE ); assert_eq!( refusal_headers(Missing::Unreadable) .get(header::CACHE_CONTROL) .unwrap(), "no-store" ); } /// 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, art: &no_art(), }, )) .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 gallery is one slide per card this match can draw, and each slide /// posts the card it is showing. The address is what carries the choice, /// so what is on screen, what is copied and what an unfurler fetches are /// the same card. #[test] fn every_card_gets_a_slide_that_posts_itself() { let html = page(); let context = fixture_context(); let summary = summarise(&result()).expect("a summary"); let offered = layouts(&context, &summary, false, false); assert!(offered.len() > 1, "a fixture with something to choose"); for layout in &offered { let slug = layout.slug(); assert!( html.contains(&format!("id=\"card-{slug}\"")), "no slide for {slug}" ); // Its own picture, and its own composer aimed at its own address. assert!( html.contains(&format!("card.png?card={slug}")), "no picture for {slug}" ); assert!( html.contains(&urlencode(&format!( "https://lance.blue/reports/m1?card={slug}" ))), "no composer for {slug}" ); } // One slide stands before anything is pressed, and it is the chosen // one rather than whichever happens to be first in the document. assert_eq!(html.matches(" checked ").count(), 1); let chosen = format!("id=\"pick-{}\" checked", offered[0].slug()); assert!( html.contains(&chosen), "the opening slide is not the chosen one" ); } /// Both halves of the ask travel together. The card decides which picture /// is drawn; the perspective decides whose side it is drawn from - the /// difference between VICTORY and "@them beat @you" on the same match. A /// slide that carried only one of them would post somebody else's verdict. #[test] fn every_slide_carries_the_card_and_the_point_of_view() { let summary = summarise(&result()).expect("a summary"); let context = fixture_context(); let perspective = Perspective::read(Some("@a.example")).expect("a side"); let offered = layouts(&context, &summary, true, true); let html = share_menu(&Page { here: "https://lance.blue/reports/m1?perspective=a.example", base: "https://lance.blue/reports/m1", image: "https://lance.blue/reports/m1/card.png", home: "https://lance.blue", perspective: Some(&perspective), offered: &offered, chosen: offered[0], participant: true, units: None, }); for layout in &offered { let query = format!("?perspective=a.example&card={}", layout.slug()); // The picture on the slide. Escaped, because it is an attribute. assert!( html.contains(&escape(&format!("card.png{query}"))), "{} lost the ask on its picture", layout.slug() ); // And the address its composer posts, which is what an unfurler // will fetch and draw from. assert!( html.contains(&urlencode(&format!("https://lance.blue/reports/m1{query}"))), "{} lost the ask in its post", layout.slug() ); } } /// Turning the gallery must not move the page. A fragment link scrolls /// the targeted slide to the top of the viewport, so the arrows are /// labels for a radio group and nothing in the block is an anchor to /// itself - which is not visible to any test that only reads markup, so /// the shape of the rule is asserted alongside it. #[test] fn turning_the_gallery_does_not_move_the_page() { assert!( STYLE.contains(".pickr:checked+.slide{display:block}"), "a slide is shown by its own radio" ); assert!( !STYLE.contains(".slide:target"), "the gallery is back on fragments, and the page will jump" ); let html = page(); let block = &html[html.find("
        ").expect("a share block")..]; let block = &block[..block.find("
        ").expect("an end") + 12]; assert!( !block.contains("href=\"#"), "a fragment link inside the gallery" ); } /// The composer belongs to the player the match handed here. A reader who /// arrived from the post gets the report and no way to repost it - and /// the flag that says which is which must never reach a link, or the /// first person to open a shared post inherits it. #[test] fn only_a_participant_is_offered_the_composer() { let summary = summarise(&result()).expect("a summary"); let context = fixture_context(); let offered = layouts(&context, &summary, false, false); let menu = |participant| { share_menu(&Page { here: "https://lance.blue/reports/m1", base: "https://lance.blue/reports/m1", image: "https://lance.blue/reports/m1/card.png", home: "https://lance.blue", perspective: None, offered: &offered, chosen: offered[0], participant, units: None, }) }; assert!(menu(true).contains("Post report to Bluesky")); assert!(menu(false).is_empty(), "a reader was offered the composer"); // And the flag is not part of the ask that links are rebuilt from. let ask = Ask { perspective: Some("a.example".into()), card: Some("duel".into()), participant: Some("1".into()), }; let query = ask.query(); assert!(query.contains("perspective=a.example")); assert!(query.contains("card=duel")); assert!( !query.contains("participant"), "the participant flag reached a link: {query}" ); } /// How long the match took, which the API measures itself: the row is /// stamped when the container reports the match playable and again when /// it reports it decided. #[test] fn the_footer_says_how_long_it_took() { assert_eq!(elapsed(42), "42s"); assert_eq!(elapsed(60), "1m"); assert_eq!(elapsed(11 * 60), "11m"); assert_eq!(elapsed(72 * 60), "1h 12m"); // The fixture row runs eleven minutes. let line = fixture_context().footer_line(7); assert!(line.ends_with("11m"), "no duration in {line}"); assert!(line.contains("7 rounds"), "lost the rounds: {line}"); } /// A match still being fought, or one from before the columns existed, /// simply says nothing rather than "0m". #[test] fn an_untimed_match_says_nothing_about_time() { let row = crate::db::MatchRow { id: "m1".into(), owner_did: "did:plc:a".into(), status: "over".into(), token: "t".into(), task_arn: None, task_ip: None, created_at: 1_787_097_600, scenario: Some("data/scenarios/TrainingScenarios/1-FirstRun.mms".into()), started_at: Some(1_787_097_600), ended_at: None, }; let line = Context::read(&row, &[]).footer_line(3); assert!(line.ends_with("3 rounds"), "invented a duration: {line}"); } /// Princess fights under the site's own handle, which is also a real /// account. Every layout has to say which one took the field - the duel /// plate was the one that did not, so a 1v1 against the bot read as a /// match against @lance.blue the player. #[test] fn every_layout_marks_a_bot_seat() { let doc = result(); let summary = summarise(&doc).expect("a summary"); let context = fixture_context(); let brief = context.brief(); let camo: Vec>> = summary.forces.iter().map(|_| None).collect(); let draw = |layout, bot: bool| { let mut summary = summarise(&doc).expect("a summary"); for force in &mut summary.forces { force.bot = bot; } card::draw(&compose( &summary, Drawn { layout, brief: &brief, eyebrow: "SCENARIO: FIRST RUN", verdict: "", title: "a match", sub: "", perspective: None, camo: &camo, board: None, art: &no_art(), }, )) .expect("the card draws") }; // The two that name every side. The lance card is one force from // somebody's own point of view, which is never the bot's. for layout in [card::Layout::Dossier, card::Layout::Duel] { assert_ne!( draw(layout, false), draw(layout, true), "{} says nothing about a bot seat", layout.slug() ); } } /// The page is set in the site's faces, from a copy - the image is built /// without `web/`. Same guard as the silhouettes. #[test] fn fonts_match_the_sites() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../web/src/fonts"); if !root.is_dir() { return; } for (name, bytes) in FONTS { let theirs = std::fs::read(root.join(format!("{name}-latin.woff2"))) .unwrap_or_else(|e| panic!("{name} is embedded but not in web/: {e}")); assert_eq!(theirs, bytes, "{name} differs from the site's copy"); } } /// Nothing to turn when there is nothing to turn to. #[test] fn a_single_card_has_no_arrows() { let summary = summarise(&result()).expect("a summary"); let context = fixture_context(); let only = [card::Layout::Dossier]; let html = share_menu(&Page { here: "https://lance.blue/reports/m1", base: "https://lance.blue/reports/m1", image: "https://lance.blue/reports/m1/card.png", home: "https://lance.blue", perspective: None, offered: &only, chosen: card::Layout::Dossier, participant: true, units: None, }); let _ = (&summary, &context); assert!(!html.contains("class=\"turn"), "arrows with one card"); // The single slide still shows: its own radio is the checked one. assert!(html.contains(" checked "), "nothing shows the only card"); assert!(html.contains("Post report to Bluesky"), "still postable"); } /// 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() { assert_eq!(Missing::read("starting"), Missing::Unfinished); assert_eq!(Missing::read("running"), Missing::Unfinished); assert_eq!(Missing::read("ready"), Missing::Unfinished); assert_eq!(Missing::read("over"), Missing::Resultless); assert_eq!(Missing::read("failed"), Missing::Resultless); // An unknown word is not a live match, so it does not wait forever. assert_eq!(Missing::read("wat"), Missing::Resultless); } /// The refusal is a page, not a blank tab, and says which of the three /// happened. #[test] fn a_refusal_reads_as_a_page() { for missing in ALL_MISSING { let html = notice(missing, "https://lance.blue"); assert!(html.starts_with("")); assert!(html.contains(missing.heading())); assert!(html.contains(missing.line())); // Nothing to index, and nothing for an unfurler to draw. assert!(html.contains("")); assert!(!html.contains("og:image")); assert!(!html.contains("card.png")); // And a way out. assert!(html.contains("href=\"https://lance.blue\"")); } } /// A report that may still appear must not be cached as absent, and must /// not 404: the CDN turns a 404 into the site's own 404 page, and a card /// service that gets one records the link as having no preview. #[test] fn a_report_that_may_yet_appear_is_not_cached_as_gone() { for missing in [ Missing::Unfinished, Missing::Resultless, Missing::Unreadable, ] { assert!(missing.may_yet()); assert_eq!(missing.status(), StatusCode::SERVICE_UNAVAILABLE); let headers = refusal_headers(missing); assert_eq!(headers.get(header::CACHE_CONTROL).unwrap(), "no-store"); assert!(headers.contains_key(header::RETRY_AFTER)); } // A match nobody launched is the one permanent answer. assert!(!Missing::Unknown.may_yet()); assert_eq!(Missing::Unknown.status(), StatusCode::NOT_FOUND); assert!(!refusal_headers(Missing::Unknown).contains_key(header::RETRY_AFTER)); } /// Only the match that is going to become a report looks again. #[test] fn only_an_unfinished_match_refreshes() { assert!( notice(Missing::Unfinished, "https://lance.blue").contains("http-equiv=\"refresh\"") ); assert!( notice(Missing::Unreadable, "https://lance.blue").contains("http-equiv=\"refresh\"") ); for missing in [Missing::Unknown, Missing::Resultless] { assert!(!notice(missing, "https://lance.blue").contains("http-equiv")); } } #[test] #[ignore = "prints the three refusals for eyeballing"] fn preview_refusals() { for missing in ALL_MISSING { println!("{}", notice(missing, "https://lance.blue")); } } }