From faca50c986c981ec1ef91a4b366c50c1aed2df9b Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 19 Aug 2026 22:45:18 -0400 Subject: [PATCH] fix(post-to-bluesky): correct what the report says, and who it is for Five things a real match showed. A medium mek drew as `scorpion`, which is the quad, so a Shadow Hawk came out with four legs. The duel plate was the one layout that never marked a bot seat, so a 1v1 against Princess read as a match against the @lance.blue account. The match's own picture sat above the page as a second copy of the card, and the gallery sat below the forces rather than leading the page. The composer is now offered only to the player the match handed here, on `?participant=1`, which is set by the hand-off and by nothing else and stays out of every link the page builds - a reader arriving from the post gets the report, not a composer aimed at somebody else's match. And the footer says how long the match took. The API measures it: the row is stamped when the container reports the match playable and again when it reports the game decided, which spans the lobby and needs nothing from arena. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I4c1111136ce8da19524a133c3602caf3795fd32c --- services/api/src/card.rs | 17 ++- services/api/src/db.rs | 42 ++++++- services/api/src/matches/lobby.rs | 2 + services/api/src/matches/mod.rs | 4 + services/api/src/routes.rs | 21 +++- services/api/src/share.rs | 185 +++++++++++++++++++++++++++++- 6 files changed, 259 insertions(+), 12 deletions(-) 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"); -- 2.51.2