From e2fb0f8ca86fc879cd5dd17f1d66e70b4f9a82e1 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sat, 15 Aug 2026 00:46:49 -0400 Subject: [PATCH] feat(daily-challenge): launch, score and account for the day's fight GET /api/daily/{slug} answers how many have taken a challenge on and whether the caller has used their attempt; POST starts it against Princess. The scenario is compiled into the binary and carried in the launch manifest, so a fight no arena image has ever heard of is launchable. Scored on battle value remaining, both sides, normalised to 1,000,000: half for what you kept and half for what you took. That is what makes hiding in a corner and trading your whole force score the same 500,000, and it is why winning is not an input - Princess usually loses, so ranking on victory would rank almost everybody equal first. One attempt per player per challenge, enforced by the primary key rather than by a check somebody can forget, and claimed before the launch so two clicks cannot start two matches. It is only spent when the match produces a result: a match that fails, or whose result carries no battle value, releases it. An attempt lost to our own infrastructure is not a score. A challenge whose day has passed is playable and is not an attempt. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: Id950e4ce8e0564c32bf4c77d115e75b8c55b857f --- Cargo.lock | 1 + services/api/Cargo.toml | 3 + services/api/src/db.rs | 312 ++++++++++++++++++- services/api/src/matches/aws.rs | 27 ++ services/api/src/matches/daily.rs | 438 +++++++++++++++++++++++++++ services/api/src/matches/manifest.rs | 24 +- services/api/src/matches/mod.rs | 126 +++++++- services/api/src/routes.rs | 225 +++++++++++++- 8 files changed, 1144 insertions(+), 12 deletions(-) create mode 100644 services/api/src/matches/daily.rs diff --git a/Cargo.lock b/Cargo.lock index 3d5ea31..6e9f772 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1516,6 +1516,7 @@ dependencies = [ "base64", "cookie", "futures-util", + "headquarters-mms", "hmac 0.12.1", "http-body-util", "hyper 1.11.0", diff --git a/services/api/Cargo.toml b/services/api/Cargo.toml index aeb2384..956656c 100644 --- a/services/api/Cargo.toml +++ b/services/api/Cargo.toml @@ -18,6 +18,9 @@ name = "headquarters_api" path = "src/lib.rs" [dependencies] +# The scenario reader, for the daily challenges compiled into this binary. +headquarters-mms = { path = "../../crates/mms" } + # ws: the lobby's live-scenario channel (matches::lobby), axum's native # WebSocketUpgrade. Not needed by the match proxy - proxy.rs splices bytes # past an existing upgrade rather than terminating one. diff --git a/services/api/src/db.rs b/services/api/src/db.rs index 651f95b..3e19c6a 100644 --- a/services/api/src/db.rs +++ b/services/api/src/db.rs @@ -103,6 +103,34 @@ fn row_to_match(r: &rusqlite::Row<'_>) -> Result { }) } +/// One player's attempt at one daily challenge. +/// +/// `did` and `day` are stored and read back but nothing acts on them yet: they +/// are what a per-player history and a day's board are built from, which is +/// the next half of this epic. +#[derive(Debug, Clone)] +#[allow(dead_code, reason = "did and day are for the board and the profile")] +pub struct DailyAttempt { + pub slug: String, + pub did: String, + pub match_id: String, + /// The UTC day the attempt was started. + pub day: String, + /// None while the match is still running, or if it never produced a + /// result — though in that case the row is deleted rather than kept. + pub score: Option, +} + +fn daily_attempt_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(DailyAttempt { + slug: row.get(0)?, + did: row.get(1)?, + match_id: row.get(2)?, + day: row.get(3)?, + score: row.get(4)?, + }) +} + impl Db { pub fn open(path: &Path) -> Result { if let Some(parent) = path.parent() @@ -164,7 +192,34 @@ impl Db { PRIMARY KEY (match_id, slot) ); CREATE INDEX IF NOT EXISTS match_players_by_did - ON match_players (did) WHERE did IS NOT NULL;", + ON match_players (did) WHERE did IS NOT NULL; + + -- One attempt at a daily challenge, per player per challenge. + -- The primary key is the rule: there is no second row to write, + -- so a second attempt fails at the database rather than at a + -- check somebody can forget to make. + -- + -- A row is written when the match launches and carries the + -- player through it. `score` is null while the match runs, and + -- the row is *deleted* if the match ends without a result - + -- an attempt lost to a crash is not a score, and burning it + -- would charge the player for our own failure. + CREATE TABLE IF NOT EXISTS daily_attempt ( + slug TEXT NOT NULL, + did TEXT NOT NULL, + match_id TEXT NOT NULL, + day TEXT NOT NULL, + score INTEGER, + bv_start INTEGER, + bv_left INTEGER, + foe_bv_start INTEGER, + foe_bv_left INTEGER, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (slug, did) + ); + CREATE UNIQUE INDEX IF NOT EXISTS daily_attempt_by_match + ON daily_attempt (match_id);", ) .map_err(|e| format!("cannot migrate {}: {e}", path.display()))?; @@ -583,6 +638,151 @@ impl Db { .await } + /// How many players have taken on a challenge. Attempts, not finishers: + /// a match still running counts, because the card's figure is "how many + /// people have tried this" and a player mid-match has. + pub async fn daily_attempt_count(&self, slug: &str) -> Result { + let slug = slug.to_owned(); + self.call(move |conn| { + conn.query_row( + "SELECT COUNT(*) FROM daily_attempt WHERE slug = ?1", + [slug], + |row| row.get(0), + ) + }) + .await + } + + /// This player's attempt at this challenge, if they have used it. + pub async fn daily_attempt( + &self, + slug: &str, + did: &str, + ) -> Result, DbError> { + let (slug, did) = (slug.to_owned(), did.to_owned()); + self.call(move |conn| { + use rusqlite::OptionalExtension; + conn.query_row( + "SELECT slug, did, match_id, day, score + FROM daily_attempt WHERE slug = ?1 AND did = ?2", + (slug, did), + daily_attempt_row, + ) + .optional() + }) + .await + } + + /// The attempt a match belongs to, if it is one. Used when a match ends, + /// to decide whether there is a score to write or an attempt to release. + pub async fn daily_attempt_for_match( + &self, + match_id: &str, + ) -> Result, DbError> { + let match_id = match_id.to_owned(); + self.call(move |conn| { + use rusqlite::OptionalExtension; + conn.query_row( + "SELECT slug, did, match_id, day, score + FROM daily_attempt WHERE match_id = ?1", + [match_id], + daily_attempt_row, + ) + .optional() + }) + .await + } + + /// Take the player's one attempt at a challenge. + /// + /// False means they had already used it. The primary key is what decides + /// that, not a read followed by a write: two launches racing each other + /// would both pass a check and only one can insert. + pub async fn take_daily_attempt( + &self, + slug: &str, + did: &str, + match_id: &str, + day: &str, + ) -> Result { + let (slug, did, match_id, day) = ( + slug.to_owned(), + did.to_owned(), + match_id.to_owned(), + day.to_owned(), + ); + self.call(move |conn| { + let changed = conn.execute( + "INSERT OR IGNORE INTO daily_attempt (slug, did, match_id, day) + VALUES (?1, ?2, ?3, ?4)", + (slug, did, match_id, day), + )?; + Ok(changed > 0) + }) + .await + } + + /// Point a claimed attempt at the match it was spent on, replacing the + /// placeholder the claim was made against. + pub async fn bind_daily_attempt( + &self, + slug: &str, + did: &str, + match_id: &str, + ) -> Result<(), DbError> { + let (slug, did, match_id) = (slug.to_owned(), did.to_owned(), match_id.to_owned()); + self.call(move |conn| { + conn.execute( + "UPDATE daily_attempt SET match_id = ?3, updated_at = unixepoch() + WHERE slug = ?1 AND did = ?2", + (slug, did, match_id), + ) + .map(|_| ()) + }) + .await + } + + /// Write the score for a finished attempt. + pub async fn score_daily_attempt( + &self, + match_id: &str, + score: i64, + mine: (i64, i64), + theirs: (i64, i64), + ) -> Result<(), DbError> { + let match_id = match_id.to_owned(); + self.call(move |conn| { + conn.execute( + "UPDATE daily_attempt + SET score = ?2, bv_start = ?3, bv_left = ?4, + foe_bv_start = ?5, foe_bv_left = ?6, + updated_at = unixepoch() + WHERE match_id = ?1", + (match_id, score, mine.0, mine.1, theirs.0, theirs.1), + ) + .map(|_| ()) + }) + .await + } + + /// Give an attempt back, because the match it was spent on produced no + /// result. Deleting the row is what makes the attempt available again, + /// and it is a no-op for a match that was never a daily attempt. + /// + /// Only an unscored attempt is released: a late duplicate `error` event + /// after a match that already scored must not take the score away. + pub async fn release_daily_attempt(&self, match_id: &str) -> Result { + let match_id = match_id.to_owned(); + self.call(move |conn| { + let changed = conn.execute( + "DELETE FROM daily_attempt WHERE match_id = ?1 AND score IS NULL", + [match_id], + )?; + Ok(changed > 0) + }) + .await + } + pub async fn set_match_task(&self, id: &str, task_arn: &str) -> Result<(), DbError> { let (id, task_arn) = (id.to_owned(), task_arn.to_owned()); self.call(move |conn| { @@ -1229,4 +1429,114 @@ mod tests { Some(Some("someone.example".to_owned())) ); } + + // --- the daily challenge's one attempt -------------------------------- + // + // The rule the whole feature rests on: one attempt per player per + // challenge, and an attempt only spent on a match that produced a result. + + #[tokio::test] + async fn a_player_gets_one_attempt_at_a_challenge() { + let (_dir, db) = temp_db().await; + assert!( + db.take_daily_attempt("parity", "did:plc:a", "m1", "2026-08-15") + .await + .unwrap() + ); + assert!( + !db.take_daily_attempt("parity", "did:plc:a", "m2", "2026-08-15") + .await + .unwrap(), + "a second attempt at the same challenge was allowed" + ); + assert_eq!(db.daily_attempt_count("parity").await.unwrap(), 1); + } + + #[tokio::test] + async fn the_limit_is_per_challenge_and_per_player() { + let (_dir, db) = temp_db().await; + assert!( + db.take_daily_attempt("parity", "did:plc:a", "m1", "2026-08-15") + .await + .unwrap() + ); + // Another player, same challenge. + assert!( + db.take_daily_attempt("parity", "did:plc:b", "m2", "2026-08-15") + .await + .unwrap() + ); + // Same player, another challenge. + assert!( + db.take_daily_attempt("sinkhole", "did:plc:a", "m3", "2026-08-16") + .await + .unwrap() + ); + assert_eq!(db.daily_attempt_count("parity").await.unwrap(), 2); + assert_eq!(db.daily_attempt_count("sinkhole").await.unwrap(), 1); + } + + #[tokio::test] + async fn a_match_that_produced_no_result_gives_the_attempt_back() { + let (_dir, db) = temp_db().await; + db.take_daily_attempt("parity", "did:plc:a", "m1", "2026-08-15") + .await + .unwrap(); + assert!(db.release_daily_attempt("m1").await.unwrap()); + assert!( + db.daily_attempt("parity", "did:plc:a") + .await + .unwrap() + .is_none(), + "the attempt was not released" + ); + // And the player may take it on again. + assert!( + db.take_daily_attempt("parity", "did:plc:a", "m2", "2026-08-15") + .await + .unwrap() + ); + } + + #[tokio::test] + async fn a_scored_attempt_is_never_released() { + // A duplicate `error` event arriving after a match scored must not + // take the score away and hand back an attempt that was played. + let (_dir, db) = temp_db().await; + db.take_daily_attempt("parity", "did:plc:a", "m1", "2026-08-15") + .await + .unwrap(); + db.score_daily_attempt("m1", 750_000, (6633, 4000), (6633, 900)) + .await + .unwrap(); + assert!(!db.release_daily_attempt("m1").await.unwrap()); + let attempt = db.daily_attempt("parity", "did:plc:a").await.unwrap(); + assert_eq!(attempt.map(|a| a.score), Some(Some(750_000))); + } + + #[tokio::test] + async fn an_attempt_is_bound_to_the_match_it_was_spent_on() { + // The claim happens before the launch, against a placeholder, so the + // result has to be able to find the attempt afterwards. + let (_dir, db) = temp_db().await; + db.take_daily_attempt( + "parity", + "did:plc:a", + "pending:parity:did:plc:a", + "2026-08-15", + ) + .await + .unwrap(); + db.bind_daily_attempt("parity", "did:plc:a", "match-7") + .await + .unwrap(); + let found = db.daily_attempt_for_match("match-7").await.unwrap(); + assert_eq!(found.map(|a| a.slug), Some("parity".to_owned())); + } + + #[tokio::test] + async fn releasing_a_match_that_was_never_an_attempt_does_nothing() { + let (_dir, db) = temp_db().await; + assert!(!db.release_daily_attempt("some-other-match").await.unwrap()); + } } diff --git a/services/api/src/matches/aws.rs b/services/api/src/matches/aws.rs index 72ba118..c5ff541 100644 --- a/services/api/src/matches/aws.rs +++ b/services/api/src/matches/aws.rs @@ -107,6 +107,33 @@ impl Aws { /// /// The signature covers the bucket, the key and the method, so the container /// can only write the one artifact it asked for. + /// Read an artifact a match uploaded. + /// + /// The only read of the artifacts bucket. Everything else here signs a + /// URL and lets the container do the transfer; a finished match's result + /// has to be read by us, because it is what a score is computed from. + pub async fn get_artifact( + &self, + bucket: &str, + match_id: &str, + name: &str, + ) -> Result, AwsFailed> { + let object = self + .s3 + .get_object() + .bucket(bucket) + .key(format!("matches/{match_id}/artifacts/{name}")) + .send() + .await + .map_err(|_| AwsFailed("artifact read"))?; + let bytes = object + .body + .collect() + .await + .map_err(|_| AwsFailed("artifact body"))?; + Ok(bytes.into_bytes().to_vec()) + } + pub async fn presign_artifact_put( &self, bucket: &str, diff --git a/services/api/src/matches/daily.rs b/services/api/src/matches/daily.rs new file mode 100644 index 0000000..1178d8e --- /dev/null +++ b/services/api/src/matches/daily.rs @@ -0,0 +1,438 @@ +//! The daily challenge: one fight a day, the same for everyone, scored. +//! +//! The scenarios live beside this file and are compiled into the binary. That +//! is deliberate: the launch manifest carries the scenario itself now +//! (`scenario.content`), so the file has to be here rather than in the arena +//! image, and a challenge that is part of the binary cannot be missing from a +//! deploy that claims to serve it. +//! +//! ## What is scored +//! +//! Battle value remaining, both sides. MegaMek recomputes a unit's BV from its +//! current state, so a damaged survivor is worth less than an untouched one +//! and the ratio is "how much of this force is left" rather than "how many are +//! left". Perfect is your force untouched and theirs destroyed. +//! +//! ```text +//! score = 1_000_000 × (mine_left/mine_start + (1 − theirs_left/theirs_start)) / 2 +//! ``` +//! +//! Half the score is what you kept and half is what you took, which is what +//! stops both degenerate strategies: hiding in a corner scores 500,000 and so +//! does trading your whole force for theirs. Winning is not an input. Princess +//! usually loses, so ranking on victory would rank almost everybody equal +//! first and decide the day on nothing. +//! +//! ## One attempt +//! +//! One per player per challenge, which is what makes a score mean something. +//! The attempt is *held* while the match runs and only *burned* when the match +//! produces a result: a match that is abandoned, crashes, or is reaped +//! releases it, because an attempt lost to our own infrastructure is not a +//! score, it is a bug the player pays for. `release` is that path and +//! `matches::record_event` is what calls it. +//! +//! A challenge whose day has passed can still be played — see the archive — +//! but it is not an attempt and it is not scored. Scoring a fight after its +//! day would rank somebody who took a week against somebody who had an +//! evening. + +#[cfg(test)] +use std::sync::OnceLock; + +/// One day's fight, and the file behind it. +pub struct Challenge { + /// The id in the URL, and the name of the file. + pub slug: &'static str, + /// The UTC day this is the daily challenge. + pub date: &'static str, + /// The scenario itself, which is what the launch manifest carries. + pub scenario: &'static str, +} + +/// The faction a player takes. Every daily scenario names its sides the same +/// way, so the API does not have to parse one to know which seat is whose. +pub const PLAYER_SLOT: &str = "Challenger"; +/// The faction Princess takes. +pub const BOT_SLOT: &str = "Opposition"; + +/// The best score there is: your force untouched, theirs destroyed. +pub const PERFECT: i64 = 1_000_000; + +macro_rules! challenges { + ($(($slug:literal, $date:literal)),* $(,)?) => { + &[$(Challenge { + slug: $slug, + date: $date, + scenario: include_str!(concat!("daily/", $slug, ".mms")), + }),*] + }; +} + +/// The schedule, in day order. +/// +/// Kept here rather than read from `daily/schedule.json` at startup for the +/// same reason the scenarios are compiled in: a deploy that is missing a day +/// should not build. The JSON copy is what `mms index` reads to write the +/// site's own copy, and `schedule_matches_the_site` below is what says the two +/// have not drifted. +pub const CHALLENGES: &[Challenge] = challenges![ + ("parity", "2026-08-15"), + ("scrapyard-arithmetic", "2026-08-16"), + ("two-against-the-star", "2026-08-17"), + ("downtown-arbitration", "2026-08-18"), + ("the-long-walk", "2026-08-19"), + ("tracks-and-treads", "2026-08-20"), + ("one-of-ours", "2026-08-21"), + ("indirect-fire", "2026-08-22"), + ("sinkhole", "2026-08-23"), + ("archipelago", "2026-08-24"), +]; + +/// The challenge with this slug, whatever day it is. +pub fn challenge(slug: &str) -> Option<&'static Challenge> { + CHALLENGES.iter().find(|c| c.slug == slug) +} + +/// Today in UTC as `YYYY-MM-DD`, from the wall clock. +/// +/// UTC is the only answer that does not need a player's timezone, and "the +/// same fight as everybody else" stops being true the moment the day is +/// per-player. Computed here rather than pulled from a date crate: the API has +/// no time dependency, and this is the civil calendar, which is arithmetic. +pub fn utc_day(now: std::time::SystemTime) -> String { + let secs = now + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let (y, m, d) = civil_from_days(secs.div_euclid(86_400)); + format!("{y:04}-{m:02}-{d:02}") +} + +/// Days since 1970-01-01 to a civil date. Howard Hinnant's `civil_from_days`, +/// which is exact for every date this will ever be handed. +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z.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) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// The challenge for a UTC day: the one scheduled for it, or the most recent +/// one before it. +/// +/// The fallback is what keeps the front page from going blank the day the +/// schedule runs out; `is_current` is what says whether the answer is today's. +#[cfg_attr( + not(test), + allow( + dead_code, + reason = "the site picks the day; this is the API's own copy of the rule" + ) +)] +pub fn for_day(day: &str) -> Option<&'static Challenge> { + CHALLENGES + .iter() + .filter(|c| c.date <= day) + .max_by_key(|c| c.date) +} + +/// Whether playing this challenge right now is an attempt at the day's fight, +/// or a backlog game that does not score. +pub fn is_current(challenge: &Challenge, day: &str) -> bool { + challenge.date == day +} + +/// What one side was worth, at the start and at the end. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SideResult { + pub bv_start: i64, + pub bv_left: i64, +} + +/// The score, 0 to [`PERFECT`]. +/// +/// Half for what you kept, half for what you destroyed. A side whose starting +/// battle value is zero or missing cannot be scored — that is a broken result +/// rather than a zero — so this answers None and the attempt is released +/// rather than recorded as a defeat. +pub fn score(mine: SideResult, theirs: SideResult) -> Option { + if mine.bv_start <= 0 || theirs.bv_start <= 0 { + return None; + } + let kept = (mine.bv_left.max(0) as f64) / (mine.bv_start as f64); + let taken = 1.0 - (theirs.bv_left.max(0) as f64) / (theirs.bv_start as f64); + // Both halves are clamped before they are averaged: MegaMek can report a + // unit worth more than it started at (a scenario that damages a unit on + // load computes its opening BV after the damage), and a score above + // perfect would be a leaderboard nobody can read. + let fraction = (kept.clamp(0.0, 1.0) + taken.clamp(0.0, 1.0)) / 2.0; + Some((fraction * PERFECT as f64).round() as i64) +} + +/// The two sides of a finished daily match, read out of arena's result +/// document. +/// +/// The document names players by the slot the manifest gave them, which for a +/// daily challenge is always [`PLAYER_SLOT`] and [`BOT_SLOT`] — the scenarios +/// are written that way so this does not have to guess. An observer row is +/// neither and is skipped by name rather than by its `observer` flag, because +/// a result written before that flag was trustworthy is still a result. +pub fn sides_from_result(result: &serde_json::Value) -> Option<(SideResult, SideResult)> { + let players = result.get("players")?.as_array()?; + let find = |slot: &str| -> Option { + let row = players + .iter() + .find(|p| p.get("slot").and_then(|s| s.as_str()) == Some(slot))?; + Some(SideResult { + bv_start: row.get("bvStart")?.as_i64()?, + bv_left: row.get("bvRemaining")?.as_i64()?, + }) + }; + Some((find(PLAYER_SLOT)?, find(BOT_SLOT)?)) +} + +/// The schedule as the site has it, for the test below. +#[cfg(test)] +fn site_schedule() -> &'static serde_json::Value { + static PARSED: OnceLock = OnceLock::new(); + PARSED.get_or_init(|| { + serde_json::from_str(include_str!("daily/schedule.json")) + .expect("daily/schedule.json is valid JSON") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_schedule_here_is_the_schedule_the_site_reads() { + // Two copies exist because they are read at different times: this one + // is compiled into the binary, and daily/schedule.json is what `mms + // index` turns into the site's bundled card. A drift between them is a + // day where the front page names one fight and the API launches + // another. + let site = site_schedule().as_array().expect("an array"); + assert_eq!(site.len(), CHALLENGES.len(), "different number of days"); + for (slot, challenge) in site.iter().zip(CHALLENGES) { + assert_eq!(slot["slug"].as_str(), Some(challenge.slug)); + assert_eq!(slot["date"].as_str(), Some(challenge.date)); + } + } + + #[test] + fn every_challenge_carries_its_scenario_and_both_slots() { + for challenge in CHALLENGES { + let scenario = headquarters_mms::ScenarioV1::parse(challenge.scenario) + .unwrap_or_else(|e| panic!("{}: {e}", challenge.slug)); + let names: Vec<&str> = scenario.factions.iter().map(|f| f.name.as_str()).collect(); + assert_eq!( + names, + vec![PLAYER_SLOT, BOT_SLOT], + "{}: a daily challenge is the player's slot then the bot's", + challenge.slug + ); + for faction in &scenario.factions { + assert!( + !faction.units.is_empty(), + "{}: {} fields nothing", + challenge.slug, + faction.name + ); + } + } + } + + #[test] + fn the_schedule_is_one_challenge_a_day_in_order() { + let mut days: Vec<&str> = CHALLENGES.iter().map(|c| c.date).collect(); + let ordered = days.clone(); + days.sort_unstable(); + days.dedup(); + assert_eq!(days.len(), CHALLENGES.len(), "two challenges share a day"); + assert_eq!(days, ordered, "the schedule is out of order"); + } + + #[test] + fn the_day_is_the_utc_calendar_day() { + let at = |secs: u64| utc_day(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs)); + assert_eq!(at(0), "1970-01-01"); + assert_eq!(at(86_399), "1970-01-01"); + assert_eq!(at(86_400), "1970-01-02"); + // A leap day, and the day either side of it. + assert_eq!(at(1_709_164_800), "2024-02-29"); + assert_eq!(at(1_709_251_200), "2024-03-01"); + } + + #[test] + fn the_days_pick_is_the_one_scheduled_for_it() { + assert_eq!(for_day("2026-08-15").map(|c| c.slug), Some("parity")); + assert_eq!( + for_day("2026-08-16").map(|c| c.slug), + Some("scrapyard-arithmetic") + ); + } + + #[test] + fn a_day_before_the_schedule_has_no_challenge() { + assert!(for_day("2020-01-01").is_none()); + } + + #[test] + fn a_day_past_the_schedule_keeps_the_last_one_up_but_not_as_today() { + let last = for_day("2099-01-01").expect("the schedule does not run out entirely"); + assert_eq!(last.slug, "archipelago"); + assert!(!is_current(last, "2099-01-01")); + assert!(is_current(last, "2026-08-24")); + } + + #[test] + fn a_perfect_result_is_a_perfect_score() { + let mine = SideResult { + bv_start: 5000, + bv_left: 5000, + }; + let theirs = SideResult { + bv_start: 5000, + bv_left: 0, + }; + assert_eq!(score(mine, theirs), Some(PERFECT)); + } + + #[test] + fn losing_everything_to_an_untouched_enemy_scores_nothing() { + let mine = SideResult { + bv_start: 5000, + bv_left: 0, + }; + let theirs = SideResult { + bv_start: 5000, + bv_left: 5000, + }; + assert_eq!(score(mine, theirs), Some(0)); + } + + #[test] + fn the_two_degenerate_strategies_score_the_same() { + // Hiding: nothing lost, nothing killed. + let hiding = score( + SideResult { + bv_start: 5000, + bv_left: 5000, + }, + SideResult { + bv_start: 5000, + bv_left: 5000, + }, + ); + // Trading: everything lost, everything killed. + let trading = score( + SideResult { + bv_start: 5000, + bv_left: 0, + }, + SideResult { + bv_start: 5000, + bv_left: 0, + }, + ); + assert_eq!(hiding, Some(PERFECT / 2)); + assert_eq!(trading, Some(PERFECT / 2)); + } + + #[test] + fn damage_counts_as_well_as_kills() { + // Half your force's worth gone, three quarters of theirs. + let mine = SideResult { + bv_start: 4000, + bv_left: 2000, + }; + let theirs = SideResult { + bv_start: 4000, + bv_left: 1000, + }; + assert_eq!(score(mine, theirs), Some(625_000)); + } + + #[test] + fn a_result_with_no_denominator_cannot_be_scored() { + let mine = SideResult { + bv_start: 0, + bv_left: 0, + }; + let theirs = SideResult { + bv_start: 5000, + bv_left: 0, + }; + assert_eq!(score(mine, theirs), None); + } + + #[test] + fn a_side_worth_more_than_it_started_does_not_beat_perfect() { + let mine = SideResult { + bv_start: 1000, + bv_left: 1200, + }; + let theirs = SideResult { + bv_start: 1000, + bv_left: 0, + }; + assert_eq!(score(mine, theirs), Some(PERFECT)); + } + + #[test] + fn both_sides_are_read_out_of_a_result_document() { + let result = serde_json::json!({ + "round": 7, + "players": [ + {"name": "ArenaObserver", "slot": "ArenaObserver", "observer": true, + "team": 0, "unitsRemaining": 0, "unitsStart": 0, + "bvRemaining": 0, "bvStart": 0}, + {"name": "@someone.example", "slot": "Challenger", "observer": false, + "team": 1, "unitsRemaining": 3, "unitsStart": 4, + "bvRemaining": 4000, "bvStart": 6633}, + {"name": "@lance.blue", "slot": "Opposition", "observer": false, + "team": 2, "unitsRemaining": 1, "unitsStart": 4, + "bvRemaining": 900, "bvStart": 6633} + ] + }); + let (mine, theirs) = sides_from_result(&result).expect("both sides are there"); + assert_eq!( + mine, + SideResult { + bv_start: 6633, + bv_left: 4000 + } + ); + assert_eq!( + theirs, + SideResult { + bv_start: 6633, + bv_left: 900 + } + ); + assert!(score(mine, theirs).is_some()); + } + + #[test] + fn a_result_from_an_arena_that_does_not_report_battle_value_is_refused() { + // The shape before arena carried BV. Scoring it would need a + // denominator that is not there, and inventing one would rank a + // player on a number nobody measured. + let result = serde_json::json!({ + "round": 7, + "players": [ + {"name": "a", "slot": "Challenger", "observer": false, "unitsRemaining": 3}, + {"name": "b", "slot": "Opposition", "observer": false, "unitsRemaining": 0} + ] + }); + assert!(sides_from_result(&result).is_none()); + } +} diff --git a/services/api/src/matches/manifest.rs b/services/api/src/matches/manifest.rs index 6067cc2..510b5e2 100644 --- a/services/api/src/matches/manifest.rs +++ b/services/api/src/matches/manifest.rs @@ -8,12 +8,25 @@ /// nothing to resolve. pub const BOT_HANDLE: &str = "lance.blue"; +/// Where the fight comes from. +/// +/// A catalog match names a scenario the arena image already has. A daily +/// challenge is a file we wrote, which no image has, so the manifest carries +/// it — arena stages the bytes instead of copying them out of its library. +/// Exactly one of the two, which is arena's rule as well as ours. +#[derive(Clone, Copy)] +pub enum ScenarioSource<'a> { + /// Path into the arena image's scenario library. + Library(&'a str), + /// The `.mms` itself. + Carried(&'a str), +} + pub struct ManifestInput<'a> { pub match_id: &'a str, pub token: &'a str, pub public_url: &'a str, - /// Path into the arena image's scenario library. - pub scenario: &'a str, + pub scenario: ScenarioSource<'a>, /// One entry per slot the manifest claims. Order is preserved. pub players: &'a [Player], } @@ -103,7 +116,10 @@ pub fn build(input: &ManifestInput) -> serde_json::Value { "version": 1, "matchId": input.match_id, "expiresAt": expires_at, - "scenario": { "name": input.scenario }, + "scenario": match input.scenario { + ScenarioSource::Library(path) => serde_json::json!({ "name": path }), + ScenarioSource::Carried(text) => serde_json::json!({ "content": text }), + }, "players": players, "observer": { "enabled": true }, "upload": { @@ -185,7 +201,7 @@ mod tests { match_id: "0d9adcb5-4a3e-4b6b-9a49-000000000000", token: "tok123", public_url: "https://api.lance.blue", - scenario: "TrainingScenarios/1-FirstRun.mms", + scenario: ScenarioSource::Library("TrainingScenarios/1-FirstRun.mms"), players, } } diff --git a/services/api/src/matches/mod.rs b/services/api/src/matches/mod.rs index 2e35ad3..e35a11d 100644 --- a/services/api/src/matches/mod.rs +++ b/services/api/src/matches/mod.rs @@ -34,8 +34,9 @@ //! to redo from scratch. mod aws; +pub mod daily; pub mod lobby; -mod manifest; +pub mod manifest; pub mod scenarios; pub mod sizing; @@ -110,17 +111,121 @@ impl Matches { }) } + /// Settle a daily-challenge attempt whose match has ended. + /// + /// Two outcomes and no third: the match produced a result, in which case + /// the attempt is scored; or it did not, in which case the attempt is + /// given back. A match that crashed, was reaped, or ended without reaching + /// victory costs the player nothing — an attempt lost to our own + /// infrastructure is a bug they should not pay for, and "one attempt" only + /// means anything if it is one attempt they actually got. + /// + /// Safe to call more than once and for matches that are not attempts at + /// all: an already-scored row is left alone, and a match with no attempt + /// behind it is a no-op. The result upload can land after the event that + /// says the match ended, so this is called again when the card asks for + /// the player's score. + pub async fn settle_daily(&self, match_id: &str) { + let Ok(Some(attempt)) = self.db.daily_attempt_for_match(match_id).await else { + return; + }; + if attempt.score.is_some() { + return; + } + let bytes = match self + .aws + .get_artifact(&self.config.artifacts_bucket, match_id, "result.json") + .await + { + Ok(bytes) => bytes, + Err(_) => { + // No result yet. Whether that is "not uploaded yet" or "never + // will be" is decided by the match's own status, not here: + // releasing on a read that raced the upload would hand back an + // attempt that was actually played. + if let Ok(Some(row)) = self.db.get_match(match_id).await + && row.status == "failed" + && self + .db + .release_daily_attempt(match_id) + .await + .unwrap_or(false) + { + tracing::info!( + match_id, + slug = attempt.slug, + "daily: match failed, attempt released" + ); + } + return; + } + }; + + let Ok(result) = serde_json::from_slice::(&bytes) else { + tracing::error!(match_id, "daily: the result document is not JSON"); + return; + }; + let Some((mine, theirs)) = daily::sides_from_result(&result) else { + // A result from an arena that does not report battle value. There + // is nothing to score against, and inventing a denominator would + // rank a player on a number nobody measured — so the attempt goes + // back rather than being recorded as a defeat. + tracing::warn!( + match_id, + slug = attempt.slug, + "daily: the result carries no battle value; attempt released" + ); + let _ = self.db.release_daily_attempt(match_id).await; + return; + }; + let Some(score) = daily::score(mine, theirs) else { + tracing::warn!(match_id, slug = attempt.slug, "daily: unscorable result"); + let _ = self.db.release_daily_attempt(match_id).await; + return; + }; + if let Err(e) = self + .db + .score_daily_attempt( + match_id, + score, + (mine.bv_start, mine.bv_left), + (theirs.bv_start, theirs.bv_left), + ) + .await + { + tracing::error!(match_id, ?e, "daily: score not stored"); + return; + } + tracing::info!( + match_id, + slug = attempt.slug, + score, + bv_left = mine.bv_left, + bv_start = mine.bv_start, + foe_bv_left = theirs.bv_left, + foe_bv_start = theirs.bv_start, + "daily: scored" + ); + } + /// The launch configuration, for the legacy request shape whose scenario /// and slots come from the environment rather than the catalog. pub fn match_config(&self) -> &MatchConfig { &self.config } + /// Launch a match. + /// + /// `scenario` is where the fight comes from and `label` is what the match + /// row records it as — a library path for a catalog match, `daily:` + /// for a daily challenge, whose file is in this binary rather than in any + /// image. pub async fn start( &self, atproto: &crate::atproto::Atproto, owner_did: &str, - scenario: &str, + scenario: manifest::ScenarioSource<'_>, + label: &str, players: Vec, ) -> Result { let id = uuid::Uuid::new_v4().to_string(); @@ -198,7 +303,7 @@ impl Matches { task_arn: None, task_ip: None, created_at: 0, // the insert uses the column default - scenario: Some(scenario.to_owned()), + scenario: Some(label.to_owned()), }) .await .map_err(|e| { @@ -222,7 +327,18 @@ impl Matches { // and still sizes correctly off its seats. let humans = players.iter().filter(|p| !p.bot).count() as u32; let bots = players.len() as u32 - humans; - let units = scenarios::find(scenario).map_or(0, |s| s.unit_total()); + // A catalog scenario's unit count is in the catalog; a carried one's + // is in the file, which is right here. Both matter: unit count is what + // the host and Princess spend CPU on, and sizing a daily challenge as + // if it were empty would under-provision every one of them. + let units = match scenario { + manifest::ScenarioSource::Library(path) => { + scenarios::find(path).map_or(0, |s| s.unit_total()) + } + manifest::ScenarioSource::Carried(text) => headquarters_mms::ScenarioV1::parse(text) + .map(|s| s.factions.iter().map(|f| f.units.len()).sum::() as u32) + .unwrap_or(0), + }; let size = sizing::task_size(humans, bots, units); tracing::info!( match_id = id, @@ -251,7 +367,7 @@ impl Matches { tracing::info!( match_id = id, owner = owner_did, - scenario, + scenario = label, seats = players.len(), humans = players.iter().filter(|p| !p.bot).count(), "match: started" diff --git a/services/api/src/routes.rs b/services/api/src/routes.rs index 19c08da..00f8170 100644 --- a/services/api/src/routes.rs +++ b/services/api/src/routes.rs @@ -90,6 +90,7 @@ pub fn app(state: AppState) -> Router { post(create_flare).layer(axum::extract::DefaultBodyLimit::max(2 * 1024 * 1024)), ) .route("/api/scenarios", get(list_scenarios)) + .route("/api/daily/{slug}", get(daily_status).post(start_daily)) .route("/api/opponents", get(list_opponents)) .route("/api/opponents/suggested", get(list_suggested_opponents)) .route("/api/matches", post(create_match).get(list_matches)) @@ -1065,7 +1066,13 @@ async fn create_match( }; match matches - .start(&state.atproto, &did, &scenario, players) + .start( + &state.atproto, + &did, + crate::matches::manifest::ScenarioSource::Library(&scenario), + &scenario, + players, + ) .await { Ok(id) => Json(serde_json::json!({ "matchId": id })).into_response(), @@ -1507,7 +1514,13 @@ async fn deploy_lobby( let scenario_path = scenario_path.to_owned(); let players = lobby_seats_to_players(&state, seats).await; match matches - .start(&state.atproto, &did, &scenario_path, players) + .start( + &state.atproto, + &did, + crate::matches::manifest::ScenarioSource::Library(&scenario_path), + &scenario_path, + players, + ) .await { Ok(new_match_id) => { @@ -1670,6 +1683,13 @@ async fn match_event( return StatusCode::NO_CONTENT.into_response(); }; crate::matches::record_event(&state.db, &row, &event, &detail).await; + // A daily challenge's score is read out of the result the match just + // uploaded, so the moment the match ends is the first moment there is one. + // The upload can land after this event, which is why the card's own read + // tries again. + if let Some(matches) = state.matches.as_ref() { + matches.settle_daily(&id).await; + } StatusCode::NO_CONTENT.into_response() } @@ -1752,6 +1772,207 @@ async fn jwks(State(state): State) -> Response { Json(state.atproto.jwks()).into_response() } +// --- the daily challenge --------------------------------------------------- + +/// What is known about one challenge: how many have taken it on, and whether +/// the caller has used their attempt. +/// +/// Open to everyone. The count is on the front page, which a stranger sees, +/// and `attempted` is simply false for one — a signed-out reader is told about +/// the fight, not about a player. +async fn daily_status( + State(state): State, + Path(slug): Path, + jar: CookieJar, +) -> Response { + let Some(challenge) = crate::matches::daily::challenge(&slug) else { + return StatusCode::NOT_FOUND.into_response(); + }; + let attempts = match state.db.daily_attempt_count(challenge.slug).await { + Ok(n) => n, + Err(_) => { + tracing::error!(slug = challenge.slug, "daily: attempt count failed"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + let mine = match session_did(&state, &jar) { + None => None, + Some(did) => { + let attempt = state + .db + .daily_attempt(challenge.slug, &did) + .await + .unwrap_or(None); + // An attempt with no score whose match has ended: the result may + // have been uploaded after the event that said the match was over, + // so this is the retry. Only for the player's own attempt, so it + // is one read for one person who is waiting for exactly this. + match (&attempt, state.matches.as_ref()) { + (Some(a), Some(matches)) if a.score.is_none() => { + matches.settle_daily(&a.match_id).await; + state + .db + .daily_attempt(challenge.slug, &did) + .await + .unwrap_or(attempt) + } + _ => attempt, + } + } + }; + Json(serde_json::json!({ + "slug": challenge.slug, + "attempts": attempts, + "attempted": mine.is_some(), + "score": mine.and_then(|a| a.score), + })) + .into_response() +} + +/// Take on a challenge: launch the fight against Princess, and spend the +/// player's one attempt if this is the day's. +/// +/// A challenge whose day has passed is playable and is not an attempt. That is +/// the whole difference between the two paths here, and it is decided against +/// the server's clock rather than anything the caller sends. +async fn start_daily( + State(state): State, + Path(slug): Path, + jar: CookieJar, +) -> Response { + let Some(did) = session_did(&state, &jar) else { + return message(StatusCode::UNAUTHORIZED, "Sign in to play."); + }; + let Some(challenge) = crate::matches::daily::challenge(&slug) else { + return StatusCode::NOT_FOUND.into_response(); + }; + let Some(matches) = state.matches.as_ref() else { + return message( + StatusCode::SERVICE_UNAVAILABLE, + "Match launching is not configured here.", + ); + }; + + let day = crate::matches::daily::utc_day(std::time::SystemTime::now()); + let scored = crate::matches::daily::is_current(challenge, &day); + + // The attempt is taken before the match is launched, not after: the + // insert is the lock, and a launch that happened first would let two + // clicks start two matches and only then discover the second was not + // allowed. A launch that fails gives it back below. + if scored { + match state + .db + .take_daily_attempt( + challenge.slug, + &did, + &pending_id(challenge.slug, &did), + &day, + ) + .await + { + Ok(true) => {} + Ok(false) => { + return message( + StatusCode::CONFLICT, + "You have already taken on today's challenge.", + ); + } + Err(_) => { + tracing::error!(did, slug = challenge.slug, "daily: attempt claim failed"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + } + } + + let handle = state.db.account_handle(&did).await.ok().flatten().flatten(); + let players = vec![ + crate::matches::NewPlayer { + slot: crate::matches::daily::PLAYER_SLOT.to_owned(), + bot: false, + did: Some(did.clone()), + handle, + camo_rkey: None, + camo_png: None, + }, + crate::matches::NewPlayer { + slot: crate::matches::daily::BOT_SLOT.to_owned(), + bot: true, + did: None, + handle: None, + camo_rkey: None, + camo_png: None, + }, + ]; + + let label = format!("daily:{}", challenge.slug); + match matches + .start( + &state.atproto, + &did, + crate::matches::manifest::ScenarioSource::Carried(challenge.scenario), + &label, + players, + ) + .await + { + Ok(match_id) => { + if scored { + // The row was inserted against "pending" so that the claim + // could happen before the launch. Now it gets the match it + // was spent on, which is what lets the result find it again. + if let Err(e) = state + .db + .bind_daily_attempt(challenge.slug, &did, &match_id) + .await + { + tracing::error!( + did, + slug = challenge.slug, + match_id, + ?e, + "daily: attempt not bound to its match; it cannot be scored" + ); + } + } + tracing::info!( + did, + slug = challenge.slug, + match_id, + scored, + "daily: started" + ); + Json(serde_json::json!({ "matchId": match_id, "scored": scored })).into_response() + } + Err(e) => { + // Nothing was played, so nothing is spent. + if scored { + let _ = state + .db + .release_daily_attempt(&pending_id(challenge.slug, &did)) + .await; + } + let _ = e; + tracing::error!(did, slug = challenge.slug, "daily: launch failed"); + message( + StatusCode::BAD_GATEWAY, + "The match could not be started. Try again.", + ) + } + } +} + +/// The `match_id` an attempt holds between being claimed and being launched. +/// +/// The claim has to happen before the launch — it is the lock that stops two +/// clicks starting two matches — but the match has no id until it starts. This +/// is the placeholder that stands in, and it is per challenge and per player +/// because `daily_attempt_by_match` is a unique index: a shared placeholder +/// would have two players collide on it. +fn pending_id(slug: &str, did: &str) -> String { + format!("pending:{slug}:{did}") +} + #[cfg(test)] mod tests { use super::*; -- 2.51.2