diff --git a/services/api/src/card.rs b/services/api/src/card.rs index 47d122b..ab9b5d9 100644 --- a/services/api/src/card.rs +++ b/services/api/src/card.rs @@ -1265,7 +1265,7 @@ fn draw_duel_plate( y + 40, ink(size, INK, 0.0), ); - if force.victor { + let end = if force.victor { draw_text( canvas, &kit.display, @@ -1273,6 +1273,21 @@ fn draw_duel_plate( end + 14, y + 40, ink(18.0, ACCENT, 3.0), + ) + } else { + end + }; + // Princess fights under the site's own handle, and this plate is the one + // layout that never said so - a duel card read as though the @lance.blue + // account had taken the field. + if force.bot { + draw_text( + canvas, + &kit.display, + "AI \u{b7} PRINCESS", + end + 14, + y + 40, + ink(15.0, MUTED, 3.0), ); } diff --git a/services/api/src/db.rs b/services/api/src/db.rs index 02710e3..77cd86a 100644 --- a/services/api/src/db.rs +++ b/services/api/src/db.rs @@ -48,6 +48,11 @@ pub struct MatchRow { /// The scenario path the match launched with. None on rows that predate /// the column. pub scenario: Option, + /// When the match became playable, and when it was decided. Both None on + /// rows that predate the columns, and `ended_at` is None while a match is + /// still being fought. + pub started_at: Option, + pub ended_at: Option, } /// One seat in a match. `did` is NULL for a bot; `handle` is the display @@ -106,6 +111,8 @@ fn row_to_match(r: &rusqlite::Row<'_>) -> Result { task_ip: r.get(5)?, created_at: r.get(6)?, scenario: r.get(7)?, + started_at: r.get(8)?, + ended_at: r.get(9)?, }) } @@ -240,6 +247,12 @@ impl Db { .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?; add_column(&conn, "matches", "scenario", "TEXT") .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?; + // When the match became playable and when it was decided. Stamped by + // set_match_status, so nothing has to remember to do it. + add_column(&conn, "matches", "started_at", "INTEGER") + .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?; + add_column(&conn, "matches", "ended_at", "INTEGER") + .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?; Ok(Db(Arc::new(Mutex::new(conn)))) } @@ -666,7 +679,8 @@ impl Db { self.call(move |conn| { use rusqlite::OptionalExtension; conn.query_row( - "SELECT id, owner_did, status, token, task_arn, task_ip, created_at, scenario + "SELECT id, owner_did, status, token, task_arn, task_ip, created_at, scenario, + started_at, ended_at FROM matches WHERE id = ?1", [id], row_to_match, @@ -679,8 +693,21 @@ impl Db { pub async fn set_match_status(&self, id: &str, status: &str) -> Result<(), DbError> { let (id, status) = (id.to_owned(), status.to_owned()); self.call(move |conn| { + // The two timestamps are stamped here rather than by the + // callers, because every path that ends a match goes through this + // one statement and none of them would remember. First write + // wins: a status re-asserted must not move the clock. conn.execute( - "UPDATE matches SET status = ?2, updated_at = unixepoch() WHERE id = ?1", + "UPDATE matches + SET status = ?2, + updated_at = unixepoch(), + started_at = CASE + WHEN started_at IS NULL AND ?2 IN ('running', 'ready') + THEN unixepoch() ELSE started_at END, + ended_at = CASE + WHEN ended_at IS NULL AND ?2 IN ('over', 'failed') + THEN unixepoch() ELSE ended_at END + WHERE id = ?1", (id, status), ) .map(|_| ()) @@ -925,7 +952,8 @@ impl Db { let owner_did = owner_did.to_owned(); self.call(move |conn| { let mut statement = conn.prepare( - "SELECT id, owner_did, status, token, task_arn, task_ip, created_at, scenario + "SELECT id, owner_did, status, token, task_arn, task_ip, created_at, scenario, + started_at, ended_at FROM matches WHERE status != 'lobby' AND (owner_did = ?1 @@ -1000,6 +1028,8 @@ mod tests { task_ip: None, created_at: 0, scenario: None, + started_at: None, + ended_at: None, }) .await .unwrap(); @@ -1032,6 +1062,8 @@ mod tests { task_ip: None, created_at: 0, scenario: Some("TrainingScenarios/1-FirstRun.mms".into()), + started_at: None, + ended_at: None, }) .await .unwrap(); @@ -1053,6 +1085,8 @@ mod tests { task_ip: None, created_at: 0, scenario: Some("TrainingScenarios/1-FirstRun.mms".into()), + started_at: None, + ended_at: None, }) .await .unwrap(); @@ -1088,6 +1122,8 @@ mod tests { task_ip: None, created_at: 0, scenario: Some("TrainingScenarios/1-FirstRun.mms".into()), + started_at: None, + ended_at: None, }) .await .unwrap(); diff --git a/services/api/src/matches/lobby.rs b/services/api/src/matches/lobby.rs index 1721d22..26040f1 100644 --- a/services/api/src/matches/lobby.rs +++ b/services/api/src/matches/lobby.rs @@ -355,6 +355,8 @@ impl Lobbies { task_arn: None, task_ip: None, created_at: 0, // the insert uses the column default + started_at: None, + ended_at: None, scenario: Some(scenario.to_owned()), }) .await diff --git a/services/api/src/matches/mod.rs b/services/api/src/matches/mod.rs index ef1c869..f4a15d2 100644 --- a/services/api/src/matches/mod.rs +++ b/services/api/src/matches/mod.rs @@ -417,6 +417,8 @@ impl Matches { task_arn: None, task_ip: None, created_at: 0, // the insert uses the column default + started_at: None, + ended_at: None, scenario: Some(label.to_owned()), }) .await @@ -1009,6 +1011,8 @@ mod tests { task_arn: None, task_ip: None, created_at: 0, + started_at: None, + ended_at: None, scenario: None, } } diff --git a/services/api/src/routes.rs b/services/api/src/routes.rs index 5558c62..add7829 100644 --- a/services/api/src/routes.rs +++ b/services/api/src/routes.rs @@ -1961,14 +1961,19 @@ async fn match_result( // Without the leading "@": that is the form the page reads back and the // form its own links carry, so an address a player is handed and one they // copy off the page are the same address. - let perspective = mine + // + // `participant` is what tells the report its reader fought in the match, + // which is what puts the composer on the page. It is set here and nowhere + // else, and the page keeps it out of every link it builds - a reader who + // arrives from the resulting post gets the report, not the composer. + let ask = mine .map(|handle| { format!( - "?perspective={}", + "?perspective={}&participant=1", crate::share::urlencode(handle.trim_start_matches('@')) ) }) - .unwrap_or_default(); + .unwrap_or_else(|| "?participant=1".to_owned()); Json(serde_json::json!({ "status": view.status, "result": matches.result(&id).await, @@ -1983,7 +1988,7 @@ async fn match_result( "{}/reports/{}{}", state.share_origin.as_deref().unwrap_or(matches.public_url()), id, - perspective + ask ), })) .into_response() @@ -2721,6 +2726,8 @@ mod tests { task_ip: None, created_at: 0, // the insert uses the column default scenario: None, + started_at: None, + ended_at: None, }) .await .unwrap(); @@ -3229,6 +3236,8 @@ mod tests { task_ip: None, created_at: 0, scenario: None, + started_at: None, + ended_at: None, }) .await .unwrap(); @@ -3262,6 +3271,8 @@ mod tests { task_ip: None, created_at: 0, scenario: None, + started_at: None, + ended_at: None, }) .await .unwrap(); @@ -3723,6 +3734,8 @@ mod tests { task_ip: None, created_at: 0, scenario: None, + started_at: None, + ended_at: None, }) .await .unwrap(); diff --git a/services/api/src/share.rs b/services/api/src/share.rs index 0790392..3c7c948 100644 --- a/services/api/src/share.rs +++ b/services/api/src/share.rs @@ -108,6 +108,8 @@ pub struct Context { 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 { @@ -133,6 +135,14 @@ impl 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, } } @@ -202,10 +212,28 @@ impl Context { "{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 @@ -412,9 +440,11 @@ fn sprite_for(unit: &serde_json::Value) -> &'static str { .and_then(serde_json::Value::as_f64) .unwrap_or(0.0); match kind { + // Three steps rather than four, because the three are the bipeds. + // A medium mek used to draw as `scorpion`, which is the quad - so a + // Shadow Hawk came out with four legs and no arms. "mek" if tons >= 80.0 => "atlas", - "mek" if tons >= 60.0 => "timberwolf", - "mek" if tons >= 40.0 => "scorpion", + "mek" if tons >= 40.0 => "timberwolf", "mek" => "panther", "battle armor" | "infantry" => "elemental", "protomek" => "roc", @@ -1296,6 +1326,12 @@ fn unit_id(force: usize, unit: usize) -> String { /// 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)> = page .offered .iter() @@ -1699,6 +1735,7 @@ pub async fn page( perspective: perspective.as_ref(), offered: &offered, chosen, + participant: ask.participant.is_some(), }, )), ) @@ -1721,6 +1758,8 @@ struct Page<'a> { /// 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, } fn document(summary: &Summary, context: &Context, page: &Page) -> String { @@ -1806,9 +1845,8 @@ fn document(summary: &Summary, context: &Context, page: &Page) -> String {

{eyebrow}

\n\

{title}

\n\

{context_line}{context_sep}Round {round}

\n\ - \"The\n\ -
    {rows}
{modals}\n\ {share}\n\ +
    {rows}
{modals}\n\

Play lance.blue\ {tagline}

\n\

lance.blue

\n\ @@ -1958,6 +1996,16 @@ pub struct Ask { 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 { @@ -2391,6 +2439,9 @@ mod tests { // 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), }, &[], ) @@ -2411,6 +2462,7 @@ mod tests { perspective: None, offered: &offered, chosen: offered[0], + participant: true, }, ) } @@ -2427,6 +2479,9 @@ mod tests { 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), }, &[], ) @@ -2640,6 +2695,8 @@ mod tests { task_ip: None, created_at: 1_787_097_600, scenario: Some(scenario.to_owned()), + started_at: None, + ended_at: None, }; let human = |did: &str| crate::db::MatchPlayer { slot: did.to_owned(), @@ -3221,6 +3278,7 @@ mod tests { perspective: Some(&perspective), offered: &offered, chosen: offered[0], + participant: true, }); for layout in &offered { let query = format!("?perspective=a.example&card={}", layout.slug()); @@ -3257,6 +3315,124 @@ mod tests { ); } + /// 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, + }) + }; + 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, + }, + )) + .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() + ); + } + } + /// Nothing to turn when there is nothing to turn to. #[test] fn a_single_card_has_no_arrows() { @@ -3271,6 +3447,7 @@ mod tests { perspective: None, offered: &only, chosen: card::Layout::Dossier, + participant: true, }); let _ = (&summary, &context); assert!(!html.contains("class=\"turn"), "arrows with one card");