diff --git a/crates/mms/src/lib.rs b/crates/mms/src/lib.rs index 13f7be7..524eaea 100644 --- a/crates/mms/src/lib.rs +++ b/crates/mms/src/lib.rs @@ -189,6 +189,7 @@ pub fn factions(text: &str) -> Vec { name: f.name.clone(), team: f.team, units: f.units.len(), + deploy: None, }) .collect() }) @@ -200,6 +201,7 @@ pub fn factions(text: &str) -> Vec { name: f.name.clone(), team: f.team, units: f.units.len(), + deploy: f.deploy.clone(), }) .collect(), None => Vec::new(), @@ -214,4 +216,10 @@ pub struct Faction { pub team: Option, /// How many machines it fields. pub units: usize, + /// Where on the board it starts - "N", "SW", "CTR". V2 only: a V1 file + /// carries the same idea in a companion key this parser does not read. + /// + /// The site stands each force in its zone when it draws a scenario, and a + /// card of a finished match wants the same picture. + pub deploy: Option, } diff --git a/plan/post-to-bluesky.md b/plan/post-to-bluesky.md index 886419b..8479f86 100644 --- a/plan/post-to-bluesky.md +++ b/plan/post-to-bluesky.md @@ -264,6 +264,13 @@ this epic does not answer it. plain sheet that says nobody has drawn its class one yet. The match report inside the container hands the player here rather than drawing any of it. +- [x] **The daily card is the front page's card.** Same board figure with + each force standing in the zone it deploys to, same label, name and + description, same head-to-head with each side lit in its own colour - + and the result where the way in used to be. It reads the challenge's + own `.mms` through the `mms` crate rather than restating any of it, so + the site's index and this card cannot drift: `Faction` carries `deploy` + now, which is what the front page places forces by. - [x] **The card is drawn, per match and per point of view.** `card.rs` is a drawing kit - canvas, glyphs through `ab_glyph`, silhouettes, bars - and a match card is one composition of it, so a later card (a profile, a diff --git a/services/api/src/card.rs b/services/api/src/card.rs index 3f2078c..ce24ae0 100644 --- a/services/api/src/card.rs +++ b/services/api/src/card.rs @@ -109,9 +109,16 @@ pub struct Force<'a> { pub units_remaining: i64, pub units_start: i64, pub bv_share: i64, + /// The battle value behind the share, so a side made of several forces + /// can be totalled rather than averaged. + pub bv_remaining: i64, + pub bv_start: i64, /// The force's own camo, as the match staged it. Every machine on the /// card is painted in it, the way MegaMek painted them on the board. pub camo: Option>, + /// Where it started, from the scenario: "N", "SW", "CTR". The front page + /// stands each force in its zone, and the daily card does the same. + pub zone: Option, pub machines: Vec>, } @@ -148,6 +155,9 @@ pub struct Card<'a> { pub verdict: &'a str, /// The line under it: the handles, or the challenge's name. pub title: &'a str, + /// The scenario's own description, which is what the front page prints + /// under a challenge's name. Empty for anything that has none. + pub blurb: &'a str, pub sub: &'a str, pub forces: Vec>, /// The final board, as arena uploaded it. Absent for a match that sent @@ -538,6 +548,29 @@ fn clipped( (String::from("…"), size) } +/// A paragraph broken into lines that fit. +fn wrapped(font: &FontRef, text: &str, size: f32, room: f32) -> Vec { + let mut lines: Vec = Vec::new(); + let mut line = String::new(); + for word in text.split_whitespace() { + let candidate = if line.is_empty() { + word.to_owned() + } else { + format!("{line} {word}") + }; + if measure(font, &candidate, size, 0.0) <= room || line.is_empty() { + line = candidate; + } else { + lines.push(std::mem::take(&mut line)); + line = word.to_owned(); + } + } + if !line.is_empty() { + lines.push(line); + } + lines +} + /// The largest size at or under `size` that fits `room`, so a long pair of /// handles shrinks rather than running off the card. fn fitted(font: &FontRef, text: &str, size: f32, tracking: f32, room: f32) -> f32 { @@ -1251,109 +1284,148 @@ fn draw_duel_plate( canvas.rect(x + 20, y + height - 14, filled, 4, tint, 1.0); } -/// The day's challenge, drawn the way the front page draws it. +/// The day's challenge, drawn as the front page draws it. /// -/// Same furniture, in the same order: the label, the challenge's own name, -/// and the head-to-head battle value with each side's figure lit in its own -/// colour and the gap between them in the middle. What the front page ends -/// with is the way in; a card of a finished one ends with the stamp instead. +/// Deliberately the same card, not a card in the same colours: the board +/// figure with each force standing in the zone it deployed to, the label, +/// the challenge's name, its own description, and the head-to-head with each +/// side's figure lit in its colour. What the front page ends with is the way +/// in; a finished one ends with the result. fn draw_daily(canvas: &mut Canvas, kit: &Kit, card: &Card) { - draw_ground(canvas, card, 0.80, 0.14); - let y = draw_heading(canvas, kit, card); + draw_ground(canvas, card, 0.86, 0.10); - let Some(force) = card.forces.iter().find(|f| f.mine).or(card.forces.first()) else { + let Some(mine) = card.forces.iter().find(|f| f.mine).or(card.forces.first()) else { return; }; - let room = (WIDTH as i32 - MARGIN * 2 - 280) as f32; - let (title, size) = clipped(&kit.display, card.title, 76.0, 34.0, 0.0, room); + // The figure, in the left half, inside its own frame. + let figure = Box { + x: MARGIN - 16, + y: MARGIN - 8, + width: 520, + height: HEIGHT as i32 - MARGIN * 2 + 20, + }; + draw_board_figure(canvas, kit, card, figure); + + // The column, in the front page's own order. + let x = figure.x + figure.width + 34; + let room = (WIDTH as i32 - MARGIN - x) as f32; + let mut y = MARGIN + 22; + draw_text( canvas, &kit.display, - &title, - MARGIN, - y + 82, - ink(size, INK, 0.0), + "DAILY CHALLENGE", + x, + y, + ink(19.0, BRAND, 4.0), ); - draw_footer(canvas, kit, card); - draw_stamp(canvas, kit, force.victor, y + 34); + y += 46; + let (title, size) = clipped(&kit.display, card.title, 46.0, 26.0, 0.0, room); + draw_text(canvas, &kit.display, &title, x, y, ink(size, INK, 0.0)); + + if !card.blurb.is_empty() { + y += 20; + for line in wrapped(&kit.text, card.blurb, 22.0, room) + .into_iter() + .take(4) + { + y += 28; + draw_text(canvas, &kit.text, &line, x, y, ink(22.0, MUTED, 0.0)); + } + } - // The head-to-head, which is the front page card's centrepiece: two - // figures, each in its side's colour, and what stands between them. - let mine = force; - let theirs = card.forces.iter().find(|f| !std::ptr::eq(*f, mine)); - let bv_y = y + 210; - draw_side_figure(canvas, kit, mine, MARGIN, bv_y, ACCENT, false); - if let Some(theirs) = theirs { - draw_side_figure( + // The head-to-head. Left is whoever asked, as the front page's left is + // always the player's own side. + y += 66; + // Everyone else, added up. A daily fields three pirate lances against one + // assault; naming the first of them and printing its battle value alone + // is a lie about what was fought. + let opposition: Vec<&Force> = card + .forces + .iter() + .filter(|f| !std::ptr::eq(*f, mine)) + .collect(); + draw_bv_side( + canvas, + kit, + &[mine], + Figure { + x, + baseline: y, + colour: ACCENT, + label: "CHALLENGER", + right: false, + }, + ); + if !opposition.is_empty() { + let right = WIDTH as i32 - MARGIN; + let label = if opposition.len() > 1 { + format!("{} LANCES", opposition.len()) + } else { + String::from("OPFOR") + }; + draw_bv_side( canvas, kit, - theirs, - WIDTH as i32 - MARGIN, - bv_y, - DANGER, - true, + &opposition, + Figure { + x: right, + baseline: y, + colour: DANGER, + label: &label, + right: true, + }, ); - // The middle: what the fight was measured in, and by how much it went - // one way. The front page prints the gap before the fight; this is - // what it came to. - let middle = WIDTH as i32 / 2; - let system = "BATTLE VALUE"; - let width = measure(&kit.display, system, 17.0, 3.0) as i32; + let middle = (x + right) / 2; + let system = "BV 2.0"; + let width = measure(&kit.display, system, 13.0, 4.0) as i32; draw_text( canvas, &kit.display, system, middle - width / 2, - bv_y - 34, - ink(17.0, MUTED, 3.0), - ); - // What separated them, said as a difference rather than as a figure - // that repeats the left-hand one: with both percentages already on - // the card, "71%" in the middle is the same number a third time. - let gap = (mine.bv_share - theirs.bv_share).abs(); - let gap_text = if gap == 0 { - String::from("even") - } else { - format!("{gap} apart") - }; - let width = measure(&kit.display, &gap_text, 30.0, 0.0) as i32; - draw_text( - canvas, - &kit.display, - &gap_text, - middle - width / 2, - bv_y, - ink(30.0, INK, 0.0), + y - 12, + ink(13.0, BRAND, 4.0), ); } - // The player's own machines under it all. - // Clear of the standing line above them. - draw_machines( - canvas, - mine, - MARGIN, - bv_y + 78, - WIDTH as i32 - MARGIN * 2, - 104, - ); + // Where the front page puts its way in, this puts what happened. + y += 74; + draw_result_plate(canvas, kit, mine, x, y); + + draw_footer(canvas, kit, card); } -/// One side's figure, lit in its own colour: the number, then who it belongs -/// to. Right-aligned when it is the far side of the head-to-head. -fn draw_side_figure( - canvas: &mut Canvas, - kit: &Kit, - force: &Force, +/// Where one side's figure goes, and how it is set. +struct Figure<'a> { x: i32, baseline: i32, colour: [u8; 3], + label: &'a str, + /// Right-aligned, for the far side of the head-to-head. right: bool, -) { - let figure = format!("{}%", force.bv_share); - let width = measure(&kit.display, &figure, 64.0, 0.0) as i32; +} + +/// One side of the head-to-head: what it has left, of what it brought. +fn draw_bv_side(canvas: &mut Canvas, kit: &Kit, side: &[&Force], at: Figure) { + let Figure { + x, + baseline, + colour, + label, + right, + } = at; + let left_bv: i64 = side.iter().map(|f| f.bv_remaining).sum(); + let start_bv: i64 = side.iter().map(|f| f.bv_start).sum(); + let share = if start_bv > 0 { + (left_bv * 100 / start_bv).clamp(0, 100) + } else { + 0 + }; + let figure = format!("{share}%"); + let width = measure(&kit.display, &figure, 40.0, 0.0) as i32; let start = if right { x - width } else { x }; draw_text( canvas, @@ -1361,53 +1433,227 @@ fn draw_side_figure( &figure, start, baseline, - ink(64.0, colour, 0.0), + ink(40.0, colour, 0.0), ); - let (name, size) = clipped(&kit.display, force.name, 24.0, 15.0, 0.0, 300.0); - let name_width = measure(&kit.display, &name, size, 0.0) as i32; - let name_x = if right { x - name_width } else { x }; + let width = measure(&kit.display, label, 13.0, 4.0) as i32; + let start = if right { x - width } else { x }; draw_text( canvas, &kit.display, - &name, - name_x, - baseline + 30, - ink(size, INK, 0.0), + label, + start, + baseline + 20, + ink(13.0, MUTED, 4.0), ); let standing = format!( "{} of {} standing", - force.units_remaining, force.units_start + side.iter().map(|f| f.units_remaining).sum::(), + side.iter().map(|f| f.units_start).sum::(), ); - let width = measure(&kit.text, &standing, 21.0, 0.0) as i32; - let standing_x = if right { x - width } else { x }; + let width = measure(&kit.text, &standing, 18.0, 0.0) as i32; + let start = if right { x - width } else { x }; draw_text( canvas, &kit.text, &standing, - standing_x, - baseline + 56, - ink(21.0, MUTED, 0.0), + start, + baseline + 42, + ink(18.0, FAINT, 0.0), ); } -/// CLEARED or FAILED, boxed, top right. -fn draw_stamp(canvas: &mut Canvas, kit: &Kit, cleared: bool, y: i32) { - let stamp = if cleared { "CLEARED" } else { "FAILED" }; - let tint = if cleared { BRAND } else { DANGER }; - let width = measure(&kit.display, stamp, 34.0, 4.0) as i32 + 40; - let x = WIDTH as i32 - MARGIN - width; - canvas.rect(x, y, width, 2, tint, 1.0); - canvas.rect(x, y + 62, width, 2, tint, 1.0); - canvas.rect(x, y, 2, 64, tint, 1.0); - canvas.rect(x + width - 2, y, 2, 64, tint, 1.0); +/// The plate the front page's Play control stands in, saying what happened +/// instead of offering a fight: the outcome, and whose it was. +fn draw_result_plate(canvas: &mut Canvas, kit: &Kit, force: &Force, x: i32, y: i32) { + let (word, tint) = if force.victor { + ("CLEARED", BRAND) + } else { + ("FAILED", DANGER) + }; + let notch = 12; + let lit_width = measure(&kit.display, word, 24.0, 5.0) as i32 + 56; + let height = 50; + // The lit half, notched at its top left the way the site cuts its plate. + for row in 0..height { + let inset = (notch - row).max(0); + canvas.rect(x + inset, y + row, lit_width - inset, 1, tint, 1.0); + } draw_text( canvas, &kit.display, - stamp, - x + 20, - y + 44, - ink(34.0, tint, 4.0), + word, + x + 30, + y + 34, + ink(24.0, BACKDROP, 5.0), ); + + // The grey half beside it, carrying whose match this was. + let (name, size) = clipped(&kit.display, force.name, 17.0, 12.0, 3.0, 320.0); + let grey_x = x + lit_width + 2; + let grey_width = measure(&kit.display, &name, size, 3.0) as i32 + 40; + for row in 0..height { + let cut = (row - (height - notch)).max(0); + canvas.rect(grey_x, y + row, grey_width - cut, 1, LINE, 0.92); + } + draw_text( + canvas, + &kit.display, + &name, + grey_x + 20, + y + 33, + ink(size, INK, 3.0), + ); +} + +/// The board figure: every force standing where it deployed, named the way +/// the front page names them. +fn draw_board_figure(canvas: &mut Canvas, kit: &Kit, card: &Card, area: Box) { + let Box { + x, + y, + width, + height, + } = area; + canvas.rect(x, y, width, height, [0x09, 0x0d, 0x13], 0.72); + // The frame, and the hairline inside it the front page draws. + for (bx, by, bw, bh) in [ + (x, y, width, 1), + (x, y + height - 1, width, 1), + (x, y, 1, height), + (x + width - 1, y, 1, height), + ] { + canvas.rect(bx, by, bw, bh, LINE, 1.0); + } + for (bx, by, bw, bh) in [ + (x + 10, y + 10, width - 20, 1), + (x + 10, y + height - 11, width - 20, 1), + (x + 10, y + 10, 1, height - 20), + (x + width - 11, y + 10, 1, height - 20), + ] { + canvas.rect(bx, by, bw, bh, BRAND, 0.28); + } + + // Where each force stands. The scenario's own zones, read through the + // mms crate - the same field the front page places them by. + let rows = height - 40; + for force in &card.forces { + let (place_y, place_x) = zone_place(force.zone.as_deref(), force.mine); + let cell_y = y + 24 + (rows as f32 * place_y) as i32; + let cell_x = x + 20 + ((width - 40) as f32 * place_x) as i32; + // A force in a corner shares the width with the force in the other + // corner, so it stands two abreast; one holding an edge or the middle + // has the frame to itself and stands in line. + let abreast = if corner(force.zone.as_deref()) { 2 } else { 4 }; + draw_figure_force(canvas, kit, force, cell_x, cell_y, width - 40, abreast); + } +} + +/// Whether a zone puts a force in a corner, where it shares the width. +fn corner(zone: Option<&str>) -> bool { + matches!( + zone.map(str::to_uppercase).as_deref(), + Some("NE" | "SE" | "SW" | "NW") + ) +} + +/// A deploy zone as a place in the frame: across, then down. A force with no +/// zone falls to its own half - the reader's at the bottom, the opposition at +/// the top, which is how the front page arranges a fight it cannot place. +fn zone_place(zone: Option<&str>, mine: bool) -> (f32, f32) { + let default = if mine { (0.68, 0.5) } else { (0.06, 0.5) }; + let Some(zone) = zone else { + return default; + }; + match zone.to_uppercase().as_str() { + "N" => (0.06, 0.5), + "NE" => (0.06, 0.78), + "E" => (0.38, 0.82), + "SE" => (0.68, 0.78), + "S" => (0.68, 0.5), + "SW" => (0.68, 0.22), + "W" => (0.38, 0.18), + "NW" => (0.06, 0.22), + "CTR" | "C" => (0.38, 0.5), + _ => default, + } +} + +/// One force in the figure: its machines in a row, with the first one named +/// under it the way the front page names each machine. +fn draw_figure_force( + canvas: &mut Canvas, + kit: &Kit, + force: &Force, + cx: i32, + y: i32, + room: i32, + abreast: i32, +) { + let count = force.machines.len().min(4) as i32; + if count == 0 { + return; + } + let per_row = abreast.min(count).max(1); + let size = if per_row > 2 { 54 } else { 60 }; + let gap = 14; + let width = per_row * size + (per_row - 1) * gap; + let x = (cx - width / 2).max(cx - room / 2); + let camo = force.camo.as_ref().and_then(|bytes| decode(bytes)); + let tint = if force.victor { ACCENT } else { DANGER }; + + for (index, machine) in force.machines.iter().take(4).enumerate() { + let index = index as i32; + let mx = x + (index % per_row) * (size + gap); + let y = y + (index / per_row) * (size * 72 / 84 + 40); + if let Some(sprite) = decode(machine.sprite) { + let painted = paint(&sprite, camo.as_ref()); + draw_sprite(canvas, &painted, mx, y, size, machine.alive); + } + // Chassis over model, in the side's colour, as on the front page. + let (chassis, model) = split_designation(machine.name); + let (chassis, size_c) = + clipped(&kit.display, &chassis, 13.0, 9.0, 1.0, (size + gap) as f32); + let width = measure(&kit.display, &chassis, size_c, 1.0) as i32; + draw_text( + canvas, + &kit.display, + &chassis, + mx + (size - width) / 2, + y + size * 72 / 84 + 16, + ink(size_c, if machine.alive { tint } else { MUTED }, 1.0), + ); + if let Some(model) = model { + let (model, size_m) = clipped(&kit.text, &model, 12.0, 9.0, 1.0, (size + gap) as f32); + let width = measure(&kit.text, &model, size_m, 1.0) as i32; + draw_text( + canvas, + &kit.text, + &model, + mx + (size - width) / 2, + y + size * 72 / 84 + 30, + ink(size_m, FAINT, 1.0), + ); + } + } +} + +/// "Atlas AS7-D" into its chassis and its model, the way a record sheet sets +/// them: the name a person says, and the variant under it. +fn split_designation(name: &str) -> (String, Option) { + match name.rsplit_once(' ') { + // A model is the last word when it looks like one: capitals, digits + // and dashes. "Timber Wolf" is not a model; "AS7-D" is. + Some((head, tail)) + if !head.is_empty() + && tail.chars().any(|c| c.is_ascii_digit()) + && tail + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-') => + { + (head.to_uppercase(), Some(tail.to_owned())) + } + _ => (name.to_uppercase(), None), + } } diff --git a/services/api/src/share.rs b/services/api/src/share.rs index e7dc063..218ec58 100644 --- a/services/api/src/share.rs +++ b/services/api/src/share.rs @@ -58,6 +58,43 @@ impl Entry { } } +/// 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. /// @@ -65,6 +102,8 @@ impl Entry { /// 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. @@ -91,6 +130,7 @@ impl Context { Entry::Scenario }; Context { + slug: label.strip_prefix("daily:").map(str::to_owned), scenario: scenario_name(&label), played: day_of(row.created_at), entry, @@ -113,6 +153,21 @@ impl Context { 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. /// @@ -1610,10 +1665,12 @@ pub async fn card( 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, @@ -1695,6 +1752,8 @@ fn layouts(context: &Context, summary: &Summary, perspective: bool) -> Vec { 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. @@ -1710,6 +1769,7 @@ struct Drawn<'a> { fn compose<'a>(summary: &'a Summary, drawn: Drawn<'a>) -> card::Card<'a> { let Drawn { layout, + brief, eyebrow, verdict, title, @@ -1730,12 +1790,18 @@ fn compose<'a>(summary: &'a Summary, drawn: Drawn<'a>) -> card::Card<'a> { 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 + (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() @@ -1758,6 +1824,7 @@ fn compose<'a>(summary: &'a Summary, drawn: Drawn<'a>) -> card::Card<'a> { card::Card { layout, verdict, + blurb: &brief.blurb, eyebrow, title, sub, @@ -1780,15 +1847,9 @@ fn headline( if layout == card::Layout::Daily && let Some(name) = &context.scenario { - let mut sub = verdict.to_owned(); - if let Entry::Daily { day } = &context.entry - && !day.is_empty() - { - sub.push_str(" · "); - sub.push_str(day); - } - sub.push_str(&format!(" · round {round}")); - return (name.clone(), sub); + // 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)) } @@ -2362,6 +2423,7 @@ mod tests { &summary, Drawn { layout: card::Layout::Dossier, + brief: &Brief::default(), eyebrow: "SCENARIO: FIRST RUN", verdict: "VICTORY", title: "@a.example won", @@ -2411,7 +2473,7 @@ mod tests { serde_json::json!({ "id": 1, "name": name, "slot": slot, "observer": false, "team": team, "unitsRemaining": left, "unitsStart": start, - "bvRemaining": bv, "bvStart": 2000, "units": units, + "bvRemaining": bv, "bvStart": start * 2000, "units": units, }) }; @@ -2455,6 +2517,27 @@ mod tests { }), 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. ( @@ -2583,10 +2666,12 @@ mod tests { }; 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, @@ -2604,6 +2689,28 @@ mod tests { } } + /// 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]