From 48365ca4cdace1cfb2aed4ed12a9f77668688afd Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sat, 15 Aug 2026 00:37:02 -0400 Subject: [PATCH] feat(daily-challenge): put the day's real fight on the front page The card was drawing an invented fight out of a placeholder file. It now draws the scheduled one: the day's name and blurb are the scenario's own Name= and Description=, the battle values are what MegaMek computes for those pilots, and the board is where the units actually stand, scaled onto a schematic grid. A UTC day, because "the same fight as everyone else" has to mean the same fight and a per-player day would make that untrue. The pick is a schedule rather than a computation over the list, so appending an eleventh challenge cannot change which fight last Tuesday was. Two new ways out of the card: the scenario file, served statically from public/ so a download does not depend on the control plane, and #daily - the archive of every challenge whose day has come, each playable and none of them scored. The attempt count is the one live figure on the page, and the card is written so that losing it costs the count and nothing else. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: Ife91b1d1d6934704fb23e1d15042faa5059d69d5 --- Cargo.lock | 3 + crates/mms/Cargo.toml | 20 +- crates/mms/src/bin/mms.rs | 385 ++++++++++++++++++ .../api/src/matches/daily/archipelago.mms | 22 + .../matches/daily/downtown-arbitration.mms | 24 ++ .../api/src/matches/daily/indirect-fire.mms | 21 + .../api/src/matches/daily/one-of-ours.mms | 20 + services/api/src/matches/daily/parity.mms | 24 ++ services/api/src/matches/daily/report.json | 194 +++++++++ services/api/src/matches/daily/schedule.json | 12 + .../matches/daily/scrapyard-arithmetic.mms | 23 ++ services/api/src/matches/daily/sinkhole.mms | 22 + .../api/src/matches/daily/the-long-walk.mms | 22 + .../src/matches/daily/tracks-and-treads.mms | 22 + .../matches/daily/two-against-the-star.mms | 22 + web/package.json | 1 + web/public/daily/archipelago.mms | 22 + web/public/daily/downtown-arbitration.mms | 24 ++ web/public/daily/indirect-fire.mms | 21 + web/public/daily/one-of-ours.mms | 20 + web/public/daily/parity.mms | 24 ++ web/public/daily/scrapyard-arithmetic.mms | 23 ++ web/public/daily/sinkhole.mms | 22 + web/public/daily/the-long-walk.mms | 22 + web/public/daily/tracks-and-treads.mms | 22 + web/public/daily/two-against-the-star.mms | 22 + web/scripts/daily-public.mjs | 71 ++++ web/scripts/daily.test.mjs | 173 +++++--- web/src/api.ts | 29 ++ web/src/chrome.ts | 5 +- web/src/content/daily.json | 305 ++++++++++++-- web/src/destinations.ts | 14 +- web/src/router.ts | 9 + web/src/screens/daily-archive.ts | 99 +++++ web/src/screens/daily.ts | 288 +++++++------ web/src/screens/home.ts | 10 +- web/src/styles.css | 40 +- 37 files changed, 1879 insertions(+), 223 deletions(-) create mode 100644 crates/mms/src/bin/mms.rs create mode 100644 services/api/src/matches/daily/archipelago.mms create mode 100644 services/api/src/matches/daily/downtown-arbitration.mms create mode 100644 services/api/src/matches/daily/indirect-fire.mms create mode 100644 services/api/src/matches/daily/one-of-ours.mms create mode 100644 services/api/src/matches/daily/parity.mms create mode 100644 services/api/src/matches/daily/report.json create mode 100644 services/api/src/matches/daily/schedule.json create mode 100644 services/api/src/matches/daily/scrapyard-arithmetic.mms create mode 100644 services/api/src/matches/daily/sinkhole.mms create mode 100644 services/api/src/matches/daily/the-long-walk.mms create mode 100644 services/api/src/matches/daily/tracks-and-treads.mms create mode 100644 services/api/src/matches/daily/two-against-the-star.mms create mode 100644 web/public/daily/archipelago.mms create mode 100644 web/public/daily/downtown-arbitration.mms create mode 100644 web/public/daily/indirect-fire.mms create mode 100644 web/public/daily/one-of-ours.mms create mode 100644 web/public/daily/parity.mms create mode 100644 web/public/daily/scrapyard-arithmetic.mms create mode 100644 web/public/daily/sinkhole.mms create mode 100644 web/public/daily/the-long-walk.mms create mode 100644 web/public/daily/tracks-and-treads.mms create mode 100644 web/public/daily/two-against-the-star.mms create mode 100644 web/scripts/daily-public.mjs create mode 100644 web/src/screens/daily-archive.ts diff --git a/Cargo.lock b/Cargo.lock index 409710c..3d5ea31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1541,6 +1541,9 @@ dependencies = [ [[package]] name = "headquarters-mms" version = "0.1.0" +dependencies = [ + "serde_json", +] [[package]] name = "heck" diff --git a/crates/mms/Cargo.toml b/crates/mms/Cargo.toml index a09058d..6bb2856 100644 --- a/crates/mms/Cargo.toml +++ b/crates/mms/Cargo.toml @@ -4,7 +4,21 @@ version.workspace = true edition.workspace = true publish.workspace = true -# No dependencies on purpose. The format is lines of `key=value`, and the -# thing that makes a parser of it correct is matching MegaMek's own reader -# quirk for quirk - not the convenience of a crate. +# The library has no dependencies on purpose. The format is lines of +# `key=value`, and what makes a parser of it correct is matching MegaMek's own +# reader quirk for quirk — not the convenience of a crate. +# +# The `mms` binary is a different matter: it reads arena's report and writes +# the site's index, both JSON, and hand-rolling that is how you end up with a +# scanner that cannot tell a faction from a unit because both are `{"name":`. +# So serde_json is optional and the binary requires the feature that pulls it +# in, which keeps the library dependency-free for anything that links it. +[features] +cli = ["dep:serde_json"] + [dependencies] +serde_json = { workspace = true, optional = true } + +[[bin]] +name = "mms" +required-features = ["cli"] diff --git a/crates/mms/src/bin/mms.rs b/crates/mms/src/bin/mms.rs new file mode 100644 index 0000000..1de3c83 --- /dev/null +++ b/crates/mms/src/bin/mms.rs @@ -0,0 +1,385 @@ +//! `mms` — read scenario files, and build the site's daily-challenge index. +//! +//! Two subcommands, and they are the two halves of "generate a scenario and +//! know it is good": +//! +//! ```text +//! mms check ... parse, report quirks, refuse a bad file +//! mms index --scenarios \ build web/src/content/daily.json +//! --schedule \ +//! --report \ +//! --out [--check] +//! ``` +//! +//! `check` is what a generator runs on its own output. It is not the whole +//! check — a file can parse and still name a board this image has never had, +//! which only MegaMek can tell you, and arena's `ScenarioReport` is where that +//! happens. +//! +//! `index` is the other direction: the front page is served from S3 and holds +//! its own copy of everything on it, so what the card shows has to be computed +//! here and committed. Everything in the output comes from the scenario files +//! except battle value and introduction year, which are not in a `.mms` at all +//! — those come from `--report`, which is arena's `ScenarioReport` output. + +use std::collections::BTreeMap; +use std::path::Path; +use std::process::ExitCode; + +use headquarters_mms::v1::Quirk; +use headquarters_mms::{ScenarioV1, Version, detect_version, parse}; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let Some(command) = args.first() else { + eprintln!("usage: mms ..."); + return ExitCode::from(2); + }; + let result = match command.as_str() { + "check" => check(&args[1..]), + "index" => index(&args[1..]), + other => Err(format!( + "unknown command '{other}'; expected check or index" + )), + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("mms: {message}"); + ExitCode::FAILURE + } + } +} + +/// `--name value` pairs, plus bare flags. +fn options(args: &[String]) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut i = 0; + while i < args.len() { + let key = args[i].trim_start_matches("--").to_string(); + let value = args + .get(i + 1) + .filter(|v| !v.starts_with("--")) + .cloned() + .unwrap_or_default(); + let step = if value.is_empty() { 1 } else { 2 }; + out.insert(key, value); + i += step; + } + out +} + +fn check(args: &[String]) -> Result<(), String> { + if args.is_empty() { + return Err("check needs at least one file".into()); + } + let mut failed = 0; + for path in args { + let text = read(Path::new(path))?; + match parse(&text) { + Err(e) => { + println!("FAIL {path}\n {e}"); + failed += 1; + } + Ok(scenario) => { + let version = match scenario.version() { + Version::V1 => "v1", + Version::V2 => "v2", + }; + println!( + "ok {path} [{version}] {}", + scenario.name().unwrap_or("-") + ); + if let headquarters_mms::Scenario::V1(v1) = &scenario { + for quirk in &v1.quirks { + println!(" note: {}", describe(quirk)); + } + } + } + } + } + if failed > 0 { + return Err(format!("{failed} file(s) did not parse")); + } + Ok(()) +} + +fn describe(quirk: &Quirk) -> String { + match quirk { + Quirk::UnreadFactionKey { key, read_as } => { + format!("{key} is never read; MegaMek looks up {read_as}") + } + Quirk::UnknownUnitDataKey { key } => { + format!("{key} is not a key ScenarioV1 acts on; it is dropped") + } + Quirk::UnmatchableUnitDataKey { key } => { + format!("{key} can never match the unit-data pattern, which wants a capital") + } + Quirk::UnusedFacing { unit, facing } => { + format!("{unit} faces '{facing}', which is not a facing; it will face N") + } + } +} + +/// The card's board is a schematic, not the real one: 32×17 hexes of unit art +/// would be a 2688-pixel-wide picture. Positions are scaled into this grid, +/// which is the size the card draws. +const GRID_COLUMNS: i32 = 8; +const GRID_ROWS: i32 = 3; + +fn index(args: &[String]) -> Result<(), String> { + let opts = options(args); + let scenarios = opts + .get("scenarios") + .ok_or("index needs --scenarios ")?; + let schedule_path = opts + .get("schedule") + .ok_or("index needs --schedule ")?; + let report_path = opts.get("report").ok_or("index needs --report ")?; + let out_path = opts.get("out").ok_or("index needs --out ")?; + let checking = opts.contains_key("check"); + + let schedule = Schedule::read(Path::new(schedule_path))?; + let report = Report::read(Path::new(report_path))?; + + let mut entries = Vec::new(); + for slot in &schedule.0 { + let path = Path::new(scenarios).join(format!("{}.mms", slot.slug)); + let text = read(&path)?; + if detect_version(&text) != Some(Version::V1) { + return Err(format!( + "{}: a daily challenge must be MMSVersion=1", + path.display() + )); + } + let scenario = ScenarioV1::parse(&text).map_err(|e| format!("{}: {e}", path.display()))?; + entries.push(entry(&slot.slug, &slot.date, &scenario, &report)?); + } + + let json = format!( + "{{\n \"challenges\": [\n{}\n ]\n}}\n", + entries.join(",\n") + ); + + if checking { + let current = read(Path::new(out_path))?; + if current == json { + println!("{out_path} is current ({} challenges)", schedule.0.len()); + return Ok(()); + } + return Err(format!( + "{out_path} is stale; re-run without --check to rewrite it" + )); + } + std::fs::write(out_path, &json).map_err(|e| format!("{out_path}: {e}"))?; + println!("wrote {out_path} ({} challenges)", schedule.0.len()); + Ok(()) +} + +fn entry(slug: &str, date: &str, scenario: &ScenarioV1, report: &Report) -> Result { + let name = scenario.name.clone().unwrap_or_default(); + let blurb = scenario.description.clone().unwrap_or_default(); + let cols = scenario.board.map_width * scenario.board.board_width; + let rows = scenario.board.map_height * scenario.board.board_height; + + let mut sides = Vec::new(); + let mut years: Option<(i32, i32)> = None; + for faction in &scenario.factions { + let measured = report + .side(slug, &faction.name) + .ok_or_else(|| format!("{slug}: the report has no faction '{}'", faction.name))?; + let mut units = Vec::new(); + // Taken is the set of grid cells already used: two units a metre apart + // on a 32-hex board land on the same schematic cell, and a unit that + // vanished under another would be a unit the card does not show. + let mut taken: Vec<(i32, i32)> = Vec::new(); + for unit in &faction.units { + let (col, row) = match unit.position { + Some((x, y)) => free( + scale(x, cols, GRID_COLUMNS), + scale(y, rows, GRID_ROWS), + &mut taken, + ), + // Undeployed units are drawn on their own edge, because the + // card is a picture of who is in the fight rather than of + // where everybody stood. + None => free(0, 0, &mut taken), + }; + let year = measured + .years + .get(&unit.chassis_short()) + .copied() + .unwrap_or_default(); + if year > 0 { + years = Some(match years { + None => (year, year), + Some((lo, hi)) => (lo.min(year), hi.max(year)), + }); + } + units.push(format!( + " {{ \"name\": {}, \"col\": {col}, \"row\": {row} }}", + json_string(&unit.chassis) + )); + } + sides.push(format!( + " {{\n \"name\": {},\n \"bv\": {},\n \"units\": [\n{}\n ]\n }}", + json_string(&faction.name), + measured.bv, + units.join(",\n") + )); + } + + let (from, to) = years.unwrap_or((0, 0)); + Ok(format!( + " {{\n \"slug\": {},\n \"date\": {},\n \"name\": {},\n \"blurb\": {},\n \"years\": {{ \"from\": {from}, \"to\": {to} }},\n \"board\": {{ \"columns\": {GRID_COLUMNS}, \"rows\": {GRID_ROWS} }},\n \"sides\": [\n{}\n ]\n }}", + json_string(slug), + json_string(date), + json_string(&name), + json_string(&blurb), + sides.join(",\n") + )) +} + +/// A 1-based hex coordinate onto a 0-based schematic cell. +fn scale(value: i32, from: i32, to: i32) -> i32 { + if from <= 1 { + return 0; + } + (((value - 1) * to) / from).clamp(0, to - 1) +} + +/// The first free cell at or after `(col, row)`, scanning along the row then +/// down. Deterministic, so the committed file does not move on a rebuild. +fn free(col: i32, row: i32, taken: &mut Vec<(i32, i32)>) -> (i32, i32) { + for step in 0..(GRID_COLUMNS * GRID_ROWS) { + let c = (col + step) % GRID_COLUMNS; + let r = (row + (col + step) / GRID_COLUMNS) % GRID_ROWS; + if !taken.contains(&(c, r)) { + taken.push((c, r)); + return (c, r); + } + } + (col, row) +} + +/// `[{ "slug": "...", "date": "YYYY-MM-DD" }, ...]`, in the order the days run. +struct Schedule(Vec); + +struct Slot { + slug: String, + date: String, +} + +impl Schedule { + fn read(path: &Path) -> Result { + let text = read(path)?; + let parsed: serde_json::Value = + serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?; + let mut out = Vec::new(); + for slot in parsed.as_array().ok_or("the schedule must be an array")? { + let slug = slot["slug"] + .as_str() + .ok_or("a schedule entry has no slug")? + .to_string(); + let date = slot["date"] + .as_str() + .ok_or_else(|| format!("{slug} has no date"))? + .to_string(); + out.push(Slot { slug, date }); + } + if out.is_empty() { + return Err(format!("{}: no entries", path.display())); + } + Ok(Schedule(out)) + } +} + +/// What arena's `ScenarioReport` measured: battle value per side, and the +/// introduction year per unit. Neither is in a `.mms`; both are MegaMek's to +/// know, which is why they come in from outside rather than being computed +/// here. +struct Report(Vec); + +struct Measured { + file: String, + faction: String, + bv: i64, + years: BTreeMap, +} + +impl Report { + fn read(path: &Path) -> Result { + let text = read(path)?; + let parsed: serde_json::Value = + serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?; + let mut out = Vec::new(); + for scenario in parsed.as_array().ok_or("the report must be an array")? { + if let Some(error) = scenario["error"].as_str() { + return Err(format!( + "the report says {} did not load: {error}", + scenario["file"].as_str().unwrap_or("a scenario") + )); + } + let file = scenario["file"].as_str().unwrap_or_default().to_string(); + for faction in scenario["factions"].as_array().into_iter().flatten() { + let mut years = BTreeMap::new(); + for unit in faction["units"].as_array().into_iter().flatten() { + if let (Some(name), Some(year)) = (unit["name"].as_str(), unit["year"].as_i64()) + { + years.insert(name.to_string(), year as i32); + } + } + out.push(Measured { + file: file.clone(), + faction: faction["name"].as_str().unwrap_or_default().to_string(), + bv: faction["bv"].as_i64().unwrap_or_default(), + years, + }); + } + } + if out.is_empty() { + return Err(format!("{}: no factions in the report", path.display())); + } + Ok(Report(out)) + } + + fn side(&self, slug: &str, faction: &str) -> Option<&Measured> { + self.0 + .iter() + .find(|m| m.faction == faction && m.file.contains(&format!("{slug}.mms"))) + } +} + +fn read(path: &Path) -> Result { + std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display())) +} + +fn json_string(value: &str) -> String { + let mut out = String::from("\""); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +trait ShortName { + fn chassis_short(&self) -> String; +} + +impl ShortName for headquarters_mms::v1::Unit { + /// MegaMek's `getShortName()` drops a `(Standard)` model, so the report + /// names four of these units differently from the scenario file. Match + /// the report's spelling when looking a unit up in it. + fn chassis_short(&self) -> String { + self.chassis.replace(" (Standard)", "") + } +} diff --git a/services/api/src/matches/daily/archipelago.mms b/services/api/src/matches/daily/archipelago.mms new file mode 100644 index 0000000..00ab1c6 --- /dev/null +++ b/services/api/src/matches/daily/archipelago.mms @@ -0,0 +1,22 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=Archipelago +Description=Four light machines and two mediums among islands. Water is a wall for some of these and a road for others. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 7/16x17 Archipelago 1,Map Set 7/16x17 Archipelago 1 +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Commando COM-7S,Challenger 1,4,5,N,6,16 +Unit_Challenger_2=Razorback RZK-9S,Challenger 2,4,5,N,13,15 +Unit_Challenger_3=Savannah Master Hovercraft (Standard),Challenger 3,4,5,N,19,16 +Unit_Challenger_4=Stinger STG-3R,Challenger 4,4,5,N,26,15 +Unit_Opposition_1=Drillson Heavy Hover Tank (Standard),Opposition 1,4,5,S,11,2 +Unit_Opposition_2=Osiris OSR-3D,Opposition 2,4,5,S,21,3 diff --git a/services/api/src/matches/daily/downtown-arbitration.mms b/services/api/src/matches/daily/downtown-arbitration.mms new file mode 100644 index 0000000..9e07485 --- /dev/null +++ b/services/api/src/matches/daily/downtown-arbitration.mms @@ -0,0 +1,24 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=Downtown Arbitration +Description=Eight mediums in a downtown grid. Every street is a firing lane and every corner is cover, depending which side of it you are standing on. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 6/16x17 City (Downtown),Map Set 6/16x17 City (Downtown) +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Clint CLNT-5U,Challenger 1,4,5,N,6,16 +Unit_Challenger_2=Crusader CRD-3R,Challenger 2,4,5,N,13,15 +Unit_Challenger_3=Griffin GRF-1N,Challenger 3,4,5,N,19,16 +Unit_Challenger_4=Javelin JVN-11D,Challenger 4,4,5,N,26,15 +Unit_Opposition_1=Hellspawn HSN-7D,Opposition 1,4,5,S,6,2 +Unit_Opposition_2=Tessen TSN-1C,Opposition 2,4,5,S,13,3 +Unit_Opposition_3=Warhammer WHM-6R,Opposition 3,4,5,S,19,2 +Unit_Opposition_4=Wraith TR1,Opposition 4,4,5,S,26,3 diff --git a/services/api/src/matches/daily/indirect-fire.mms b/services/api/src/matches/daily/indirect-fire.mms new file mode 100644 index 0000000..a12b4a1 --- /dev/null +++ b/services/api/src/matches/daily/indirect-fire.mms @@ -0,0 +1,21 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=Indirect Fire +Description=Missile carriers against brawlers across open water. Whoever chooses the range wins, and the lake decides who gets to choose. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 4/16x17 Large Lakes 1,Map Set 4/16x17 Large Lakes 1 +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Archer ARC-2R,Challenger 1,4,5,N,8,16 +Unit_Challenger_2=Crusader CRD-3R,Challenger 2,4,5,N,16,15 +Unit_Challenger_3=Heavy LRM Carrier (Standard),Challenger 3,4,5,N,24,16 +Unit_Opposition_1=Atlas AS7-D,Opposition 1,4,5,S,11,2 +Unit_Opposition_2=Thanatos TNS-4S,Opposition 2,4,5,S,21,3 diff --git a/services/api/src/matches/daily/one-of-ours.mms b/services/api/src/matches/daily/one-of-ours.mms new file mode 100644 index 0000000..a5c978b --- /dev/null +++ b/services/api/src/matches/daily/one-of-ours.mms @@ -0,0 +1,20 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=One of Ours +Description=One Clan assault against three Inner Sphere mediums in rolling hills. It cannot catch them and they cannot afford to stand still. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 3/16x17 Rolling Hills 1,Map Set 3/16x17 Rolling Hills 1 +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Turkina D,Challenger 1,4,5,N,16,16 +Unit_Opposition_1=Hussar HSR-500-D,Opposition 1,4,5,S,8,2 +Unit_Opposition_2=Shadow Hawk SHD-2H,Opposition 2,4,5,S,16,3 +Unit_Opposition_3=Wolverine WVR-6R,Opposition 3,4,5,S,24,2 diff --git a/services/api/src/matches/daily/parity.mms b/services/api/src/matches/daily/parity.mms new file mode 100644 index 0000000..ef1436a --- /dev/null +++ b/services/api/src/matches/daily/parity.mms @@ -0,0 +1,24 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=Parity +Description=Eight heavy machines, matched as closely as battle value can match them, on the plainest map in the set. Nothing here is in your favour and nothing is against you. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 2/16x17 BattleTech,Map Set 2/16x17 BattleTech +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Archer ARC-2R,Challenger 1,4,5,N,6,16 +Unit_Challenger_2=Archer ARC-4M,Challenger 2,4,5,N,13,15 +Unit_Challenger_3=Argus AGS-4D,Challenger 3,4,5,N,19,16 +Unit_Challenger_4=Hellhound (Conjurer) (Standard),Challenger 4,4,5,N,26,15 +Unit_Opposition_1=Atlas AS7-D,Opposition 1,4,5,S,6,2 +Unit_Opposition_2=Bushwacker BSW-L1,Opposition 2,4,5,S,13,3 +Unit_Opposition_3=Enfield END-6J,Opposition 3,4,5,S,19,2 +Unit_Opposition_4=Templar TLR1-OB,Opposition 4,4,5,S,26,3 diff --git a/services/api/src/matches/daily/report.json b/services/api/src/matches/daily/report.json new file mode 100644 index 0000000..2cc4a18 --- /dev/null +++ b/services/api/src/matches/daily/report.json @@ -0,0 +1,194 @@ +[ + { + "file": "/out/daily/archipelago.mms", + "name": "Archipelago", + "description": "Four light machines and two mediums among islands. Water is a wall for some of these and a road for others.", + "singlePlayer": true, + "board": {"width": 32, "height": 17}, + "factions": [ + {"name": "Challenger", "team": 1, "start": "S", "bv": 2087, "units": [ + {"name": "Stinger STG-3R", "year": 2479, "bv": 359}, + {"name": "Savannah Master Hovercraft", "year": 3025, "bv": 215}, + {"name": "Razorback RZK-9S", "year": 3063, "bv": 855}, + {"name": "Commando COM-7S", "year": 3064, "bv": 658} + ]}, + {"name": "Opposition", "team": 2, "start": "N", "bv": 2087, "units": [ + {"name": "Drillson Heavy Hover Tank", "year": 3025, "bv": 949}, + {"name": "Osiris OSR-3D", "year": 3063, "bv": 1138} + ]} + ] + }, + { + "file": "/out/daily/downtown-arbitration.mms", + "name": "Downtown Arbitration", + "description": "Eight mediums in a downtown grid. Every street is a firing lane and every corner is cover, depending which side of it you are standing on.", + "singlePlayer": true, + "board": {"width": 32, "height": 17}, + "factions": [ + {"name": "Challenger", "team": 1, "start": "S", "bv": 4968, "units": [ + {"name": "Javelin JVN-11D", "year": 3062, "bv": 1104}, + {"name": "Griffin GRF-1N", "year": 2492, "bv": 1272}, + {"name": "Crusader CRD-3R", "year": 2752, "bv": 1317}, + {"name": "Clint CLNT-5U", "year": 3062, "bv": 1275} + ]}, + {"name": "Opposition", "team": 2, "start": "N", "bv": 4968, "units": [ + {"name": "Hellspawn HSN-7D", "year": 3062, "bv": 1220}, + {"name": "Wraith TR1", "year": 3055, "bv": 1287}, + {"name": "Tessen TSN-1C", "year": 3061, "bv": 1162}, + {"name": "Warhammer WHM-6R", "year": 2515, "bv": 1299} + ]} + ] + }, + { + "file": "/out/daily/indirect-fire.mms", + "name": "Indirect Fire", + "description": "Missile carriers against brawlers across open water. Whoever chooses the range wins, and the lake decides who gets to choose.", + "singlePlayer": true, + "board": {"width": 32, "height": 17}, + "factions": [ + {"name": "Challenger", "team": 1, "start": "S", "bv": 3742, "units": [ + {"name": "Heavy LRM Carrier", "year": 3058, "bv": 948}, + {"name": "Crusader CRD-3R", "year": 2752, "bv": 1317}, + {"name": "Archer ARC-2R", "year": 2474, "bv": 1477} + ]}, + {"name": "Opposition", "team": 2, "start": "N", "bv": 3741, "units": [ + {"name": "Atlas AS7-D", "year": 2755, "bv": 1897}, + {"name": "Thanatos TNS-4S", "year": 3061, "bv": 1844} + ]} + ] + }, + { + "file": "/out/daily/one-of-ours.mms", + "name": "One of Ours", + "description": "One Clan assault against three Inner Sphere mediums in rolling hills. It cannot catch them and they cannot afford to stand still.", + "singlePlayer": true, + "board": {"width": 32, "height": 17}, + "factions": [ + {"name": "Challenger", "team": 1, "start": "S", "bv": 3132, "units": [ + {"name": "Turkina D", "year": 3061, "bv": 3132} + ]}, + {"name": "Opposition", "team": 2, "start": "N", "bv": 3142, "units": [ + {"name": "Hussar HSR-500-D", "year": 3060, "bv": 977}, + {"name": "Shadow Hawk SHD-2H", "year": 2550, "bv": 1064}, + {"name": "Wolverine WVR-6R", "year": 2575, "bv": 1101} + ]} + ] + }, + { + "file": "/out/daily/parity.mms", + "name": "Parity", + "description": "Eight heavy machines, matched as closely as battle value can match them, on the plainest map in the set. Nothing here is in your favour and nothing is against you.", + "singlePlayer": true, + "board": {"width": 32, "height": 17}, + "factions": [ + {"name": "Challenger", "team": 1, "start": "S", "bv": 6633, "units": [ + {"name": "Hellhound (Conjurer)", "year": 2829, "bv": 1813}, + {"name": "Argus AGS-4D", "year": 3062, "bv": 1638}, + {"name": "Archer ARC-4M", "year": 3049, "bv": 1705}, + {"name": "Archer ARC-2R", "year": 2474, "bv": 1477} + ]}, + {"name": "Opposition", "team": 2, "start": "N", "bv": 6633, "units": [ + {"name": "Atlas AS7-D", "year": 2755, "bv": 1897}, + {"name": "Templar TLR1-OB", "year": 3062, "bv": 1806}, + {"name": "Bushwacker BSW-L1", "year": 3063, "bv": 1513}, + {"name": "Enfield END-6J", "year": 3060, "bv": 1417} + ]} + ] + }, + { + "file": "/out/daily/scrapyard-arithmetic.mms", + "name": "Scrapyard Arithmetic", + "description": "Five light machines against two assaults on open ground. There is nothing to hide behind, so the swarm either works or it does not.", + "singlePlayer": true, + "board": {"width": 32, "height": 17}, + "factions": [ + {"name": "Challenger", "team": 1, "start": "S", "bv": 4086, "units": [ + {"name": "Stiletto STO-4A", "year": 3061, "bv": 859}, + {"name": "Razorback RZK-9S", "year": 3063, "bv": 855}, + {"name": "Razorback RZK-9S", "year": 3063, "bv": 855}, + {"name": "Commando COM-7S", "year": 3064, "bv": 658}, + {"name": "Stiletto STO-4A", "year": 3061, "bv": 859} + ]}, + {"name": "Opposition", "team": 2, "start": "N", "bv": 4233, "units": [ + {"name": "Sagittaire SGT-8R", "year": 3063, "bv": 2088}, + {"name": "Thor (Summoner) A", "year": 2872, "bv": 2145} + ]} + ] + }, + { + "file": "/out/daily/sinkhole.mms", + "name": "Sinkhole", + "description": "Six mediums around a desert sinkhole. The short way across is the one everybody can see you take.", + "singlePlayer": true, + "board": {"width": 32, "height": 17}, + "factions": [ + {"name": "Challenger", "team": 1, "start": "S", "bv": 3547, "units": [ + {"name": "Drillson Heavy Hover Tank", "year": 3025, "bv": 949}, + {"name": "Cougar H", "year": 3061, "bv": 1323}, + {"name": "Clint CLNT-5U", "year": 3062, "bv": 1275} + ]}, + {"name": "Opposition", "team": 2, "start": "N", "bv": 3547, "units": [ + {"name": "Hellspawn HSN-7D", "year": 3062, "bv": 1220}, + {"name": "Osiris OSR-3D", "year": 3063, "bv": 1138}, + {"name": "Uziel UZL-3S", "year": 3065, "bv": 1189} + ]} + ] + }, + { + "file": "/out/daily/the-long-walk.mms", + "name": "The Long Walk", + "description": "Six heavy machines on airless moonscape. No woods, no water, no cover worth the name - only range, armour and the order you shoot in.", + "singlePlayer": true, + "board": {"width": 32, "height": 17}, + "factions": [ + {"name": "Challenger", "team": 1, "start": "S", "bv": 5079, "units": [ + {"name": "Atlas AS7-D", "year": 2755, "bv": 1897}, + {"name": "Archer ARC-4M", "year": 3049, "bv": 1705}, + {"name": "Archer ARC-2R", "year": 2474, "bv": 1477} + ]}, + {"name": "Opposition", "team": 2, "start": "N", "bv": 5079, "units": [ + {"name": "Marauder MAD-5S", "year": 3051, "bv": 1799}, + {"name": "Thanatos TNS-4S", "year": 3061, "bv": 1844}, + {"name": "Zeus ZEU-6T", "year": 3026, "bv": 1436} + ]} + ] + }, + { + "file": "/out/daily/tracks-and-treads.mms", + "name": "Tracks and Treads", + "description": "Four vehicles against two BattleMechs on a coastline. The tanks are faster and cheaper, and they cannot walk out of the water.", + "singlePlayer": true, + "board": {"width": 32, "height": 17}, + "factions": [ + {"name": "Challenger", "team": 1, "start": "S", "bv": 2327, "units": [ + {"name": "Savannah Master Hovercraft", "year": 3025, "bv": 215}, + {"name": "Savannah Master Hovercraft", "year": 3025, "bv": 215}, + {"name": "Heavy LRM Carrier", "year": 3058, "bv": 948}, + {"name": "Drillson Heavy Hover Tank", "year": 3025, "bv": 949} + ]}, + {"name": "Opposition", "team": 2, "start": "N", "bv": 2327, "units": [ + {"name": "Osiris OSR-3D", "year": 3063, "bv": 1138}, + {"name": "Uziel UZL-3S", "year": 3065, "bv": 1189} + ]} + ] + }, + { + "file": "/out/daily/two-against-the-star.mms", + "name": "Two Against the Star", + "description": "A Clan pair walks into heavy forest against four Inner Sphere mediums. Woods shorten every sightline; the question is whether four can close before two can shoot.", + "singlePlayer": true, + "board": {"width": 32, "height": 17}, + "factions": [ + {"name": "Challenger", "team": 1, "start": "S", "bv": 5065, "units": [ + {"name": "Thunderbolt TDR-5S", "year": 2505, "bv": 1335}, + {"name": "Osiris OSR-3D", "year": 3063, "bv": 1138}, + {"name": "Crusader CRD-3R", "year": 2752, "bv": 1317}, + {"name": "Clint CLNT-5U", "year": 3062, "bv": 1275} + ]}, + {"name": "Opposition", "team": 2, "start": "N", "bv": 5065, "units": [ + {"name": "Black Hawk (Nova) A", "year": 2870, "bv": 2422}, + {"name": "Thor (Summoner) D", "year": 2872, "bv": 2643} + ]} + ] + } +] diff --git a/services/api/src/matches/daily/schedule.json b/services/api/src/matches/daily/schedule.json new file mode 100644 index 0000000..7eebdbc --- /dev/null +++ b/services/api/src/matches/daily/schedule.json @@ -0,0 +1,12 @@ +[ + { "slug": "parity", "date": "2026-08-15" }, + { "slug": "scrapyard-arithmetic", "date": "2026-08-16" }, + { "slug": "two-against-the-star", "date": "2026-08-17" }, + { "slug": "downtown-arbitration", "date": "2026-08-18" }, + { "slug": "the-long-walk", "date": "2026-08-19" }, + { "slug": "tracks-and-treads", "date": "2026-08-20" }, + { "slug": "one-of-ours", "date": "2026-08-21" }, + { "slug": "indirect-fire", "date": "2026-08-22" }, + { "slug": "sinkhole", "date": "2026-08-23" }, + { "slug": "archipelago", "date": "2026-08-24" } +] diff --git a/services/api/src/matches/daily/scrapyard-arithmetic.mms b/services/api/src/matches/daily/scrapyard-arithmetic.mms new file mode 100644 index 0000000..82acd2f --- /dev/null +++ b/services/api/src/matches/daily/scrapyard-arithmetic.mms @@ -0,0 +1,23 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=Scrapyard Arithmetic +Description=Five light machines against two assaults on open ground. There is nothing to hide behind, so the swarm either works or it does not. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 5/16x17 Open Terrain 1,Map Set 5/16x17 Open Terrain 1 +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Commando COM-7S,Challenger 1,4,5,N,5,16 +Unit_Challenger_2=Razorback RZK-9S,Challenger 2,4,5,N,11,15 +Unit_Challenger_3=Razorback RZK-9S,Challenger 3,4,5,N,16,16 +Unit_Challenger_4=Stiletto STO-4A,Challenger 4,4,5,N,21,15 +Unit_Challenger_5=Stiletto STO-4A,Challenger 5,4,5,N,27,16 +Unit_Opposition_1=Sagittaire SGT-8R,Opposition 1,4,5,S,11,2 +Unit_Opposition_2=Thor (Summoner) A,Opposition 2,4,5,S,21,3 diff --git a/services/api/src/matches/daily/sinkhole.mms b/services/api/src/matches/daily/sinkhole.mms new file mode 100644 index 0000000..d6b6686 --- /dev/null +++ b/services/api/src/matches/daily/sinkhole.mms @@ -0,0 +1,22 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=Sinkhole +Description=Six mediums around a desert sinkhole. The short way across is the one everybody can see you take. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 3/16x17 Desert Sinkhole 1,Map Set 3/16x17 Desert Sinkhole 1 +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Clint CLNT-5U,Challenger 1,4,5,N,8,16 +Unit_Challenger_2=Cougar H,Challenger 2,4,5,N,16,15 +Unit_Challenger_3=Drillson Heavy Hover Tank (Standard),Challenger 3,4,5,N,24,16 +Unit_Opposition_1=Hellspawn HSN-7D,Opposition 1,4,5,S,8,2 +Unit_Opposition_2=Osiris OSR-3D,Opposition 2,4,5,S,16,3 +Unit_Opposition_3=Uziel UZL-3S,Opposition 3,4,5,S,24,2 diff --git a/services/api/src/matches/daily/the-long-walk.mms b/services/api/src/matches/daily/the-long-walk.mms new file mode 100644 index 0000000..f6e0f42 --- /dev/null +++ b/services/api/src/matches/daily/the-long-walk.mms @@ -0,0 +1,22 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=The Long Walk +Description=Six heavy machines on airless moonscape. No woods, no water, no cover worth the name - only range, armour and the order you shoot in. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 5/16x17 Moonscape 1,Map Set 5/16x17 Moonscape 1 +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Archer ARC-2R,Challenger 1,4,5,N,8,16 +Unit_Challenger_2=Archer ARC-4M,Challenger 2,4,5,N,16,15 +Unit_Challenger_3=Atlas AS7-D,Challenger 3,4,5,N,24,16 +Unit_Opposition_1=Marauder MAD-5S,Opposition 1,4,5,S,8,2 +Unit_Opposition_2=Thanatos TNS-4S,Opposition 2,4,5,S,16,3 +Unit_Opposition_3=Zeus ZEU-6T,Opposition 3,4,5,S,24,2 diff --git a/services/api/src/matches/daily/tracks-and-treads.mms b/services/api/src/matches/daily/tracks-and-treads.mms new file mode 100644 index 0000000..68b93a0 --- /dev/null +++ b/services/api/src/matches/daily/tracks-and-treads.mms @@ -0,0 +1,22 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=Tracks and Treads +Description=Four vehicles against two BattleMechs on a coastline. The tanks are faster and cheaper, and they cannot walk out of the water. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 7/16x17 Coast 1,Map Set 7/16x17 Coast 1 +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Drillson Heavy Hover Tank (Standard),Challenger 1,4,5,N,6,16 +Unit_Challenger_2=Heavy LRM Carrier (Standard),Challenger 2,4,5,N,13,15 +Unit_Challenger_3=Savannah Master Hovercraft (Standard),Challenger 3,4,5,N,19,16 +Unit_Challenger_4=Savannah Master Hovercraft (Standard),Challenger 4,4,5,N,26,15 +Unit_Opposition_1=Osiris OSR-3D,Opposition 1,4,5,S,11,2 +Unit_Opposition_2=Uziel UZL-3S,Opposition 2,4,5,S,21,3 diff --git a/services/api/src/matches/daily/two-against-the-star.mms b/services/api/src/matches/daily/two-against-the-star.mms new file mode 100644 index 0000000..66080e7 --- /dev/null +++ b/services/api/src/matches/daily/two-against-the-star.mms @@ -0,0 +1,22 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=Two Against the Star +Description=A Clan pair walks into heavy forest against four Inner Sphere mediums. Woods shorten every sightline; the question is whether four can close before two can shoot. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 4/16x17 Heavy Forest 1,Map Set 4/16x17 Heavy Forest 1 +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Clint CLNT-5U,Challenger 1,4,5,N,6,16 +Unit_Challenger_2=Crusader CRD-3R,Challenger 2,4,5,N,13,15 +Unit_Challenger_3=Osiris OSR-3D,Challenger 3,4,5,N,19,16 +Unit_Challenger_4=Thunderbolt TDR-5S,Challenger 4,4,5,N,26,15 +Unit_Opposition_1=Black Hawk (Nova) A,Opposition 1,4,5,S,11,2 +Unit_Opposition_2=Thor (Summoner) D,Opposition 2,4,5,S,21,3 diff --git a/web/package.json b/web/package.json index 6362e44..992ad66 100644 --- a/web/package.json +++ b/web/package.json @@ -22,6 +22,7 @@ "social-card": "node scripts/social-card.mjs", "megamek-camo": "node scripts/megamek-camo.mjs", "megamek-units": "node scripts/megamek-units.mjs", + "daily-public": "node scripts/daily-public.mjs", "dev:local-auth": "VITE_LOCAL_AUTH=1 astro dev", "lexgen": "lex build --lexicons ../lexicons --out src/lexicons --index-file --clear", "lexicons:update": "lex install --update --save --lexicons ../lexicons --manifest ../lexicons.json", diff --git a/web/public/daily/archipelago.mms b/web/public/daily/archipelago.mms new file mode 100644 index 0000000..00ab1c6 --- /dev/null +++ b/web/public/daily/archipelago.mms @@ -0,0 +1,22 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=Archipelago +Description=Four light machines and two mediums among islands. Water is a wall for some of these and a road for others. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 7/16x17 Archipelago 1,Map Set 7/16x17 Archipelago 1 +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Commando COM-7S,Challenger 1,4,5,N,6,16 +Unit_Challenger_2=Razorback RZK-9S,Challenger 2,4,5,N,13,15 +Unit_Challenger_3=Savannah Master Hovercraft (Standard),Challenger 3,4,5,N,19,16 +Unit_Challenger_4=Stinger STG-3R,Challenger 4,4,5,N,26,15 +Unit_Opposition_1=Drillson Heavy Hover Tank (Standard),Opposition 1,4,5,S,11,2 +Unit_Opposition_2=Osiris OSR-3D,Opposition 2,4,5,S,21,3 diff --git a/web/public/daily/downtown-arbitration.mms b/web/public/daily/downtown-arbitration.mms new file mode 100644 index 0000000..9e07485 --- /dev/null +++ b/web/public/daily/downtown-arbitration.mms @@ -0,0 +1,24 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=Downtown Arbitration +Description=Eight mediums in a downtown grid. Every street is a firing lane and every corner is cover, depending which side of it you are standing on. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 6/16x17 City (Downtown),Map Set 6/16x17 City (Downtown) +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Clint CLNT-5U,Challenger 1,4,5,N,6,16 +Unit_Challenger_2=Crusader CRD-3R,Challenger 2,4,5,N,13,15 +Unit_Challenger_3=Griffin GRF-1N,Challenger 3,4,5,N,19,16 +Unit_Challenger_4=Javelin JVN-11D,Challenger 4,4,5,N,26,15 +Unit_Opposition_1=Hellspawn HSN-7D,Opposition 1,4,5,S,6,2 +Unit_Opposition_2=Tessen TSN-1C,Opposition 2,4,5,S,13,3 +Unit_Opposition_3=Warhammer WHM-6R,Opposition 3,4,5,S,19,2 +Unit_Opposition_4=Wraith TR1,Opposition 4,4,5,S,26,3 diff --git a/web/public/daily/indirect-fire.mms b/web/public/daily/indirect-fire.mms new file mode 100644 index 0000000..a12b4a1 --- /dev/null +++ b/web/public/daily/indirect-fire.mms @@ -0,0 +1,21 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=Indirect Fire +Description=Missile carriers against brawlers across open water. Whoever chooses the range wins, and the lake decides who gets to choose. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 4/16x17 Large Lakes 1,Map Set 4/16x17 Large Lakes 1 +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Archer ARC-2R,Challenger 1,4,5,N,8,16 +Unit_Challenger_2=Crusader CRD-3R,Challenger 2,4,5,N,16,15 +Unit_Challenger_3=Heavy LRM Carrier (Standard),Challenger 3,4,5,N,24,16 +Unit_Opposition_1=Atlas AS7-D,Opposition 1,4,5,S,11,2 +Unit_Opposition_2=Thanatos TNS-4S,Opposition 2,4,5,S,21,3 diff --git a/web/public/daily/one-of-ours.mms b/web/public/daily/one-of-ours.mms new file mode 100644 index 0000000..a5c978b --- /dev/null +++ b/web/public/daily/one-of-ours.mms @@ -0,0 +1,20 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=One of Ours +Description=One Clan assault against three Inner Sphere mediums in rolling hills. It cannot catch them and they cannot afford to stand still. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 3/16x17 Rolling Hills 1,Map Set 3/16x17 Rolling Hills 1 +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Turkina D,Challenger 1,4,5,N,16,16 +Unit_Opposition_1=Hussar HSR-500-D,Opposition 1,4,5,S,8,2 +Unit_Opposition_2=Shadow Hawk SHD-2H,Opposition 2,4,5,S,16,3 +Unit_Opposition_3=Wolverine WVR-6R,Opposition 3,4,5,S,24,2 diff --git a/web/public/daily/parity.mms b/web/public/daily/parity.mms new file mode 100644 index 0000000..ef1436a --- /dev/null +++ b/web/public/daily/parity.mms @@ -0,0 +1,24 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=Parity +Description=Eight heavy machines, matched as closely as battle value can match them, on the plainest map in the set. Nothing here is in your favour and nothing is against you. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 2/16x17 BattleTech,Map Set 2/16x17 BattleTech +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Archer ARC-2R,Challenger 1,4,5,N,6,16 +Unit_Challenger_2=Archer ARC-4M,Challenger 2,4,5,N,13,15 +Unit_Challenger_3=Argus AGS-4D,Challenger 3,4,5,N,19,16 +Unit_Challenger_4=Hellhound (Conjurer) (Standard),Challenger 4,4,5,N,26,15 +Unit_Opposition_1=Atlas AS7-D,Opposition 1,4,5,S,6,2 +Unit_Opposition_2=Bushwacker BSW-L1,Opposition 2,4,5,S,13,3 +Unit_Opposition_3=Enfield END-6J,Opposition 3,4,5,S,19,2 +Unit_Opposition_4=Templar TLR1-OB,Opposition 4,4,5,S,26,3 diff --git a/web/public/daily/scrapyard-arithmetic.mms b/web/public/daily/scrapyard-arithmetic.mms new file mode 100644 index 0000000..82acd2f --- /dev/null +++ b/web/public/daily/scrapyard-arithmetic.mms @@ -0,0 +1,23 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=Scrapyard Arithmetic +Description=Five light machines against two assaults on open ground. There is nothing to hide behind, so the swarm either works or it does not. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 5/16x17 Open Terrain 1,Map Set 5/16x17 Open Terrain 1 +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Commando COM-7S,Challenger 1,4,5,N,5,16 +Unit_Challenger_2=Razorback RZK-9S,Challenger 2,4,5,N,11,15 +Unit_Challenger_3=Razorback RZK-9S,Challenger 3,4,5,N,16,16 +Unit_Challenger_4=Stiletto STO-4A,Challenger 4,4,5,N,21,15 +Unit_Challenger_5=Stiletto STO-4A,Challenger 5,4,5,N,27,16 +Unit_Opposition_1=Sagittaire SGT-8R,Opposition 1,4,5,S,11,2 +Unit_Opposition_2=Thor (Summoner) A,Opposition 2,4,5,S,21,3 diff --git a/web/public/daily/sinkhole.mms b/web/public/daily/sinkhole.mms new file mode 100644 index 0000000..d6b6686 --- /dev/null +++ b/web/public/daily/sinkhole.mms @@ -0,0 +1,22 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=Sinkhole +Description=Six mediums around a desert sinkhole. The short way across is the one everybody can see you take. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 3/16x17 Desert Sinkhole 1,Map Set 3/16x17 Desert Sinkhole 1 +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Clint CLNT-5U,Challenger 1,4,5,N,8,16 +Unit_Challenger_2=Cougar H,Challenger 2,4,5,N,16,15 +Unit_Challenger_3=Drillson Heavy Hover Tank (Standard),Challenger 3,4,5,N,24,16 +Unit_Opposition_1=Hellspawn HSN-7D,Opposition 1,4,5,S,8,2 +Unit_Opposition_2=Osiris OSR-3D,Opposition 2,4,5,S,16,3 +Unit_Opposition_3=Uziel UZL-3S,Opposition 3,4,5,S,24,2 diff --git a/web/public/daily/the-long-walk.mms b/web/public/daily/the-long-walk.mms new file mode 100644 index 0000000..f6e0f42 --- /dev/null +++ b/web/public/daily/the-long-walk.mms @@ -0,0 +1,22 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=The Long Walk +Description=Six heavy machines on airless moonscape. No woods, no water, no cover worth the name - only range, armour and the order you shoot in. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 5/16x17 Moonscape 1,Map Set 5/16x17 Moonscape 1 +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Archer ARC-2R,Challenger 1,4,5,N,8,16 +Unit_Challenger_2=Archer ARC-4M,Challenger 2,4,5,N,16,15 +Unit_Challenger_3=Atlas AS7-D,Challenger 3,4,5,N,24,16 +Unit_Opposition_1=Marauder MAD-5S,Opposition 1,4,5,S,8,2 +Unit_Opposition_2=Thanatos TNS-4S,Opposition 2,4,5,S,16,3 +Unit_Opposition_3=Zeus ZEU-6T,Opposition 3,4,5,S,24,2 diff --git a/web/public/daily/tracks-and-treads.mms b/web/public/daily/tracks-and-treads.mms new file mode 100644 index 0000000..68b93a0 --- /dev/null +++ b/web/public/daily/tracks-and-treads.mms @@ -0,0 +1,22 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=Tracks and Treads +Description=Four vehicles against two BattleMechs on a coastline. The tanks are faster and cheaper, and they cannot walk out of the water. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 7/16x17 Coast 1,Map Set 7/16x17 Coast 1 +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Drillson Heavy Hover Tank (Standard),Challenger 1,4,5,N,6,16 +Unit_Challenger_2=Heavy LRM Carrier (Standard),Challenger 2,4,5,N,13,15 +Unit_Challenger_3=Savannah Master Hovercraft (Standard),Challenger 3,4,5,N,19,16 +Unit_Challenger_4=Savannah Master Hovercraft (Standard),Challenger 4,4,5,N,26,15 +Unit_Opposition_1=Osiris OSR-3D,Opposition 1,4,5,S,11,2 +Unit_Opposition_2=Uziel UZL-3S,Opposition 2,4,5,S,21,3 diff --git a/web/public/daily/two-against-the-star.mms b/web/public/daily/two-against-the-star.mms new file mode 100644 index 0000000..66080e7 --- /dev/null +++ b/web/public/daily/two-against-the-star.mms @@ -0,0 +1,22 @@ +# A lance.blue daily challenge: one human faction (Challenger) against +# Princess (Opposition). SinglePlayer=true, so desktop MegaMek sets it up +# that way on its own. +# +# MegaMek Data (C) 2025 by The MegaMek Team is licensed under CC BY-NC-SA 4.0. +# https://creativecommons.org/licenses/by-nc-sa/4.0/ +MMSVersion=1 +Name=Two Against the Star +Description=A Clan pair walks into heavy forest against four Inner Sphere mediums. Woods shorten every sightline; the question is whether four can close before two can shoot. +SinglePlayer=true +BoardWidth=2 +BoardHeight=1 +Maps=Map Set 4/16x17 Heavy Forest 1,Map Set 4/16x17 Heavy Forest 1 +Factions=Challenger,Opposition +Location_Challenger=S +Location_Opposition=N +Unit_Challenger_1=Clint CLNT-5U,Challenger 1,4,5,N,6,16 +Unit_Challenger_2=Crusader CRD-3R,Challenger 2,4,5,N,13,15 +Unit_Challenger_3=Osiris OSR-3D,Challenger 3,4,5,N,19,16 +Unit_Challenger_4=Thunderbolt TDR-5S,Challenger 4,4,5,N,26,15 +Unit_Opposition_1=Black Hawk (Nova) A,Opposition 1,4,5,S,11,2 +Unit_Opposition_2=Thor (Summoner) D,Opposition 2,4,5,S,21,3 diff --git a/web/scripts/daily-public.mjs b/web/scripts/daily-public.mjs new file mode 100644 index 0000000..baedfd4 --- /dev/null +++ b/web/scripts/daily-public.mjs @@ -0,0 +1,71 @@ +/** + * Copies the daily-challenge scenario files into web/public/ so the site can + * hand one to a player as a download. + * + * npm run daily-public + * npm run daily-public -- --check + * + * They live under services/api/src/matches/daily/ because the API launches + * from them — it sends the file itself in the launch manifest, so it has to + * be inside the API's own build context, and web/ is not. The copies here are + * the same bytes, served statically: a download that went through the control + * plane would be a download that fails whenever the control plane does, and + * this file is what says the two copies agree. + * + * Sibling of megamek-units.mjs, and the same bargain: the output is committed, + * so neither the build nor the tests need this to have been run. + */ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repo = fileURLToPath(new URL("../..", import.meta.url)); +const from = join(repo, "services/api/src/matches/daily"); +const to = join(repo, "web/public/daily"); +const check = process.argv.includes("--check"); + +const names = (await readdir(from)).filter((n) => n.endsWith(".mms")).sort(); +if (names.length === 0) { + console.error(`no scenario files in ${from}`); + process.exit(1); +} + +let stale = 0; +for (const name of names) { + const source = await readFile(join(from, name)); + let current = null; + try { + current = await readFile(join(to, name)); + } catch { + /* not copied yet */ + } + if (current && current.equals(source)) continue; + stale += 1; + if (check) { + console.error( + `${name}: web/public/daily/ is ${current ? "stale" : "missing it"}`, + ); + } else { + await writeFile(join(to, name), source); + console.log(`copied ${name}`); + } +} + +// A file in public/ that no longer has a source is a scenario a player can +// still download and nothing can still launch. +const copied = (await readdir(to)).filter((n) => n.endsWith(".mms")); +for (const name of copied) { + if (names.includes(name)) continue; + stale += 1; + console.error(`${name}: in web/public/daily/ with no scenario behind it`); +} + +if (check && stale > 0) { + console.error(`\n${stale} file(s) out of step; run: npm run daily-public`); + process.exit(1); +} +console.log( + check + ? `${names.length} scenario files match` + : `${names.length} scenario files in public/`, +); diff --git a/web/scripts/daily.test.mjs b/web/scripts/daily.test.mjs index 49aaa4e..850cc61 100644 --- a/web/scripts/daily.test.mjs +++ b/web/scripts/daily.test.mjs @@ -1,96 +1,159 @@ /** * The daily challenge card draws content/daily.json onto a canvas, and every - * way that file can be wrong is silent: a sprite nothing imports leaves a hex - * empty, a cell off the board paints outside it, and an era the badge map has - * never heard of renders as a name with no symbol beside it. + * way that file can be wrong is silent: a unit the sprite map has never heard + * of leaves a hex empty, a cell off the board paints outside it, and two units + * in one cell is one unit invisible. * * None of that throws, and none of it is visible in a test that cannot run a * canvas — so this checks the file against the things it points at instead. * + * daily.json is generated by `mms index` from the scenario files, so the tests + * that matter here are the ones a generator can still get wrong: that the + * schedule is a schedule, that every unit named has art, and that the figures + * are figures. + * * Run with `npm test`. */ import { test } from "node:test"; import assert from "node:assert/strict"; import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; +import { readFile, readdir } from "node:fs/promises"; import { fileURLToPath } from "node:url"; -const { ERA_ICONS } = await import("../src/content/trivia.ts"); - const src = fileURLToPath(new URL("../src/", import.meta.url)); -const challenge = JSON.parse( +const repo = fileURLToPath(new URL("../..", import.meta.url)); +const { challenges } = JSON.parse( await readFile(`${src}content/daily.json`, "utf8"), ); -const units = challenge.sides.flatMap((side) => side.units); -test("the challenge names an era the badge map knows, with a symbol on disk", () => { - assert.ok( - challenge.era in ERA_ICONS, - `"${challenge.era}" is not one of the eras with a badge`, +/** The card paints through preview.ts, so its map is what says a unit has art. */ +const preview = await readFile(`${src}scenario/preview.ts`, "utf8"); +const spriteMap = + /const SPRITE_URLS[^{]*\{([\s\S]*?)\n\};/.exec(preview)?.[1] ?? ""; + +test("there is a schedule, one challenge per day, in order", () => { + assert.ok(challenges.length, "no challenges are scheduled"); + const dates = challenges.map((c) => c.date); + for (const date of dates) { + assert.match(date, /^\d{4}-\d{2}-\d{2}$/, `"${date}" is not a UTC day`); + } + assert.equal( + new Set(dates).size, + dates.length, + "two challenges are scheduled for the same day", ); - const icon = ERA_ICONS[challenge.era]; - assert.ok(icon, `"${challenge.era}" has no symbol, so the card shows none`); - assert.ok( - existsSync(`${src}../public${icon}`), - `${icon} is not in public/, so the badge is a broken image`, + assert.deepEqual(dates, [...dates].sort(), "the schedule is out of order"); + + const slugs = challenges.map((c) => c.slug); + assert.equal( + new Set(slugs).size, + slugs.length, + "two challenges share a slug", ); }); -test("every unit is a sprite that exists and is imported", async () => { - assert.ok(units.length, "the board has no units on it"); +test("every challenge has a name and a line about it", () => { + for (const { slug, name, blurb } of challenges) { + assert.ok(name?.length, `${slug} has no name`); + assert.ok(blurb?.length, `${slug} has no blurb`); + } +}); - // The card's own map of file name to imported URL. Read as text because the - // module cannot be imported here: it imports images through Vite's ?url, - // which only exists inside a build. - const card = await readFile(`${src}screens/daily.ts`, "utf8"); +test("every unit named is one the card has art for", () => { + for (const challenge of challenges) { + for (const side of challenge.sides) { + for (const unit of side.units) { + assert.ok( + spriteMap.includes(`"${unit.name}"`), + `${challenge.slug}: "${unit.name}" is not in preview.ts's sprite map, ` + + `so its hex draws empty`, + ); + } + } + } +}); - for (const { sprite } of units) { +test("every unit stands on the board, and no two share a cell", () => { + for (const challenge of challenges) { + const { columns, rows } = challenge.board; assert.ok( - existsSync(`${src}scenario/units/${sprite}`), - `${sprite} is not in src/scenario/units/`, + columns > 0 && rows > 0, + `${challenge.slug}: the board has no cells`, ); - assert.ok( - card.includes(`"${sprite}": `), - `${sprite} is placed on the board but daily.ts imports no art for it, ` + - `so its hex draws empty`, + + const cells = []; + for (const side of challenge.sides) { + for (const { name, col, row } of side.units) { + assert.ok( + Number.isInteger(col) && col >= 0 && col < columns, + `${challenge.slug}: ${name} is in column ${col}, off a board ${columns} wide`, + ); + assert.ok( + Number.isInteger(row) && row >= 0 && row < rows, + `${challenge.slug}: ${name} is in row ${row}, off a board ${rows} deep`, + ); + cells.push(`${col},${row}`); + } + } + assert.equal( + new Set(cells).size, + cells.length, + `${challenge.slug}: two units share a cell, so one is invisible`, ); } }); -test("every unit stands on the board", () => { - const { columns, rows } = challenge.board; - assert.ok(columns > 0 && rows > 0, "the board has no cells"); +test("the figures on the card are figures", () => { + for (const challenge of challenges) { + assert.equal(challenge.sides.length, 2, `${challenge.slug} is not 1v1`); + for (const side of challenge.sides) { + assert.ok(side.name.length, `${challenge.slug}: a force has no name`); + assert.ok( + Number.isInteger(side.bv) && side.bv > 0, + `${challenge.slug}: ${side.name} is worth ${side.bv}`, + ); + assert.ok( + side.units.length, + `${challenge.slug}: ${side.name} fields nothing`, + ); + } + const { from, to } = challenge.years; + assert.ok( + Number.isInteger(from) && Number.isInteger(to) && from > 0 && to >= from, + `${challenge.slug}: "${from}-${to}" is not a range of years`, + ); + } +}); - for (const { sprite, col, row } of units) { +test("every challenge is downloadable, and every download is a challenge", async () => { + const published = (await readdir(`${src}../public/daily`)).filter((n) => + n.endsWith(".mms"), + ); + for (const { slug } of challenges) { assert.ok( - Number.isInteger(col) && col >= 0 && col < columns, - `${sprite} is in column ${col}, off a board ${columns} wide`, + existsSync(`${src}../public/daily/${slug}.mms`), + `${slug}: the card offers a download that is not in public/daily/`, ); + } + for (const name of published) { + const slug = name.replace(/\.mms$/, ""); assert.ok( - Number.isInteger(row) && row >= 0 && row < rows, - `${sprite} is in row ${row}, off a board ${rows} deep`, + challenges.some((c) => c.slug === slug), + `${name} is downloadable but is not a scheduled challenge`, ); } - - // Two units in one hex is one unit invisible. - const cells = units.map(({ col, row }) => `${col},${row}`); - assert.equal(new Set(cells).size, cells.length, "two units share a hex"); }); -test("the figures on the card are figures", () => { - assert.ok(Number.isInteger(challenge.played) && challenge.played >= 0); - for (const side of challenge.sides) { - assert.ok(side.name.length, "a force has no name"); +test("the published copy is the scenario the API launches", async () => { + for (const { slug } of challenges) { + const source = await readFile( + `${repo}/services/api/src/matches/daily/${slug}.mms`, + ); + const published = await readFile(`${src}../public/daily/${slug}.mms`); assert.ok( - Number.isInteger(side.bv) && side.bv > 0, - `${side.name} is worth ${side.bv}`, + source.equals(published), + `${slug}: the file a player downloads is not the file the match runs; ` + + `run npm run daily-public`, ); - assert.equal(side.colour.length, 3, `${side.name}'s camo is not a colour`); - for (const channel of side.colour) { - assert.ok( - Number.isInteger(channel) && channel >= 0 && channel <= 255, - `${side.name}'s camo has a channel outside 0-255`, - ); - } } }); diff --git a/web/src/api.ts b/web/src/api.ts index 6a5540b..c190010 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -341,6 +341,35 @@ export interface Scenario { slots: ScenarioSlot[]; } +/** What the control plane knows about one daily challenge. */ +export interface DailyStatus { + /** The challenge this is about, so a late answer cannot be read as another's. */ + slug: string; + /** How many players have started it. Attempts, not finishers. */ + attempts: number; + /** + * Whether the signed-in player has used their attempt, and what they got. + * Both null for a stranger, and `score` is null for an attempt that is + * still running or that ended without a result. + */ + attempted: boolean; + score: number | null; +} + +/** + * The live half of the daily challenge card. + * + * Everything else on that card is bundled with the page; this is the one + * figure that has to be asked for, and the card is written so that losing it + * costs the count and nothing else. + */ +export async function dailyStatus(slug: string): Promise { + const response = await call(`/api/daily/${encodeURIComponent(slug)}`); + if (!response.ok) + throw new ApiError(`Unexpected response (${response.status}).`); + return (await response.json()) as DailyStatus; +} + /** The catalog is static for the life of a deploy, so one fetch per page. */ let scenarios: Promise | null = null; diff --git a/web/src/chrome.ts b/web/src/chrome.ts index 176889a..3e0f8ab 100644 --- a/web/src/chrome.ts +++ b/web/src/chrome.ts @@ -18,7 +18,10 @@ export type Route = | "camo" | "about" | `about/${AboutPageId}` - | "lobby"; + | "lobby" + // An address with no masthead link, reached from the front page's daily + // challenge card. See destinations.ts's EXTRA_ROUTES. + | "daily"; const navLinks = new Map(); diff --git a/web/src/content/daily.json b/web/src/content/daily.json index ae45564..6f3547b 100644 --- a/web/src/content/daily.json +++ b/web/src/content/daily.json @@ -1,28 +1,285 @@ { - "name": "Borborygmus Basin", - "blurb": "Xertz halfpace winklepicker, nudiustertian brontide. Cattywampus lollygag mollycoddle quinquagenarian.", - "era": "Clan Invasion", - "played": 412, - "board": { "columns": 7, "rows": 3 }, - "sides": [ - { - "name": "Attacker", - "colour": [86, 132, 199], - "bv": 5412, - "units": [ - { "sprite": "marauder.png", "col": 0, "row": 1 }, - { "sprite": "warhammer.png", "col": 1, "row": 0 }, - { "sprite": "griffin.png", "col": 1, "row": 2 } - ] - }, - { - "name": "Defender", - "colour": [176, 92, 72], - "bv": 5388, - "units": [ - { "sprite": "atlas.png", "col": 6, "row": 1 }, - { "sprite": "thunderbolt.png", "col": 5, "row": 0 }, - { "sprite": "shadowhawk.png", "col": 5, "row": 2 } + "challenges": [ + { + "slug": "parity", + "date": "2026-08-15", + "name": "Parity", + "blurb": "Eight heavy machines, matched as closely as battle value can match them, on the plainest map in the set. Nothing here is in your favour and nothing is against you.", + "years": { "from": 2474, "to": 3063 }, + "board": { "columns": 8, "rows": 3 }, + "sides": [ + { + "name": "Challenger", + "bv": 6633, + "units": [ + { "name": "Archer ARC-2R", "col": 1, "row": 2 }, + { "name": "Archer ARC-4M", "col": 3, "row": 2 }, + { "name": "Argus AGS-4D", "col": 4, "row": 2 }, + { "name": "Hellhound (Conjurer) (Standard)", "col": 6, "row": 2 } + ] + }, + { + "name": "Opposition", + "bv": 6633, + "units": [ + { "name": "Atlas AS7-D", "col": 1, "row": 0 }, + { "name": "Bushwacker BSW-L1", "col": 3, "row": 0 }, + { "name": "Enfield END-6J", "col": 4, "row": 0 }, + { "name": "Templar TLR1-OB", "col": 6, "row": 0 } + ] + } + ] + }, + { + "slug": "scrapyard-arithmetic", + "date": "2026-08-16", + "name": "Scrapyard Arithmetic", + "blurb": "Five light machines against two assaults on open ground. There is nothing to hide behind, so the swarm either works or it does not.", + "years": { "from": 2872, "to": 3064 }, + "board": { "columns": 8, "rows": 3 }, + "sides": [ + { + "name": "Challenger", + "bv": 4086, + "units": [ + { "name": "Commando COM-7S", "col": 1, "row": 2 }, + { "name": "Razorback RZK-9S", "col": 2, "row": 2 }, + { "name": "Razorback RZK-9S", "col": 3, "row": 2 }, + { "name": "Stiletto STO-4A", "col": 5, "row": 2 }, + { "name": "Stiletto STO-4A", "col": 6, "row": 2 } + ] + }, + { + "name": "Opposition", + "bv": 4233, + "units": [ + { "name": "Sagittaire SGT-8R", "col": 2, "row": 0 }, + { "name": "Thor (Summoner) A", "col": 5, "row": 0 } + ] + } + ] + }, + { + "slug": "two-against-the-star", + "date": "2026-08-17", + "name": "Two Against the Star", + "blurb": "A Clan pair walks into heavy forest against four Inner Sphere mediums. Woods shorten every sightline; the question is whether four can close before two can shoot.", + "years": { "from": 2505, "to": 3063 }, + "board": { "columns": 8, "rows": 3 }, + "sides": [ + { + "name": "Challenger", + "bv": 5065, + "units": [ + { "name": "Clint CLNT-5U", "col": 1, "row": 2 }, + { "name": "Crusader CRD-3R", "col": 3, "row": 2 }, + { "name": "Osiris OSR-3D", "col": 4, "row": 2 }, + { "name": "Thunderbolt TDR-5S", "col": 6, "row": 2 } + ] + }, + { + "name": "Opposition", + "bv": 5065, + "units": [ + { "name": "Black Hawk (Nova) A", "col": 2, "row": 0 }, + { "name": "Thor (Summoner) D", "col": 5, "row": 0 } + ] + } + ] + }, + { + "slug": "downtown-arbitration", + "date": "2026-08-18", + "name": "Downtown Arbitration", + "blurb": "Eight mediums in a downtown grid. Every street is a firing lane and every corner is cover, depending which side of it you are standing on.", + "years": { "from": 2492, "to": 3062 }, + "board": { "columns": 8, "rows": 3 }, + "sides": [ + { + "name": "Challenger", + "bv": 4968, + "units": [ + { "name": "Clint CLNT-5U", "col": 1, "row": 2 }, + { "name": "Crusader CRD-3R", "col": 3, "row": 2 }, + { "name": "Griffin GRF-1N", "col": 4, "row": 2 }, + { "name": "Javelin JVN-11D", "col": 6, "row": 2 } + ] + }, + { + "name": "Opposition", + "bv": 4968, + "units": [ + { "name": "Hellspawn HSN-7D", "col": 1, "row": 0 }, + { "name": "Tessen TSN-1C", "col": 3, "row": 0 }, + { "name": "Warhammer WHM-6R", "col": 4, "row": 0 }, + { "name": "Wraith TR1", "col": 6, "row": 0 } + ] + } + ] + }, + { + "slug": "the-long-walk", + "date": "2026-08-19", + "name": "The Long Walk", + "blurb": "Six heavy machines on airless moonscape. No woods, no water, no cover worth the name - only range, armour and the order you shoot in.", + "years": { "from": 2474, "to": 3061 }, + "board": { "columns": 8, "rows": 3 }, + "sides": [ + { + "name": "Challenger", + "bv": 5079, + "units": [ + { "name": "Archer ARC-2R", "col": 1, "row": 2 }, + { "name": "Archer ARC-4M", "col": 3, "row": 2 }, + { "name": "Atlas AS7-D", "col": 5, "row": 2 } + ] + }, + { + "name": "Opposition", + "bv": 5079, + "units": [ + { "name": "Marauder MAD-5S", "col": 1, "row": 0 }, + { "name": "Thanatos TNS-4S", "col": 3, "row": 0 }, + { "name": "Zeus ZEU-6T", "col": 5, "row": 0 } + ] + } + ] + }, + { + "slug": "tracks-and-treads", + "date": "2026-08-20", + "name": "Tracks and Treads", + "blurb": "Four vehicles against two BattleMechs on a coastline. The tanks are faster and cheaper, and they cannot walk out of the water.", + "years": { "from": 3025, "to": 3065 }, + "board": { "columns": 8, "rows": 3 }, + "sides": [ + { + "name": "Challenger", + "bv": 2327, + "units": [ + { "name": "Drillson Heavy Hover Tank (Standard)", "col": 1, "row": 2 }, + { "name": "Heavy LRM Carrier (Standard)", "col": 3, "row": 2 }, + { "name": "Savannah Master Hovercraft (Standard)", "col": 4, "row": 2 }, + { "name": "Savannah Master Hovercraft (Standard)", "col": 6, "row": 2 } + ] + }, + { + "name": "Opposition", + "bv": 2327, + "units": [ + { "name": "Osiris OSR-3D", "col": 2, "row": 0 }, + { "name": "Uziel UZL-3S", "col": 5, "row": 0 } + ] + } + ] + }, + { + "slug": "one-of-ours", + "date": "2026-08-21", + "name": "One of Ours", + "blurb": "One Clan assault against three Inner Sphere mediums in rolling hills. It cannot catch them and they cannot afford to stand still.", + "years": { "from": 2550, "to": 3061 }, + "board": { "columns": 8, "rows": 3 }, + "sides": [ + { + "name": "Challenger", + "bv": 3132, + "units": [ + { "name": "Turkina D", "col": 3, "row": 2 } + ] + }, + { + "name": "Opposition", + "bv": 3142, + "units": [ + { "name": "Hussar HSR-500-D", "col": 1, "row": 0 }, + { "name": "Shadow Hawk SHD-2H", "col": 3, "row": 0 }, + { "name": "Wolverine WVR-6R", "col": 5, "row": 0 } + ] + } + ] + }, + { + "slug": "indirect-fire", + "date": "2026-08-22", + "name": "Indirect Fire", + "blurb": "Missile carriers against brawlers across open water. Whoever chooses the range wins, and the lake decides who gets to choose.", + "years": { "from": 2474, "to": 3061 }, + "board": { "columns": 8, "rows": 3 }, + "sides": [ + { + "name": "Challenger", + "bv": 3742, + "units": [ + { "name": "Archer ARC-2R", "col": 1, "row": 2 }, + { "name": "Crusader CRD-3R", "col": 3, "row": 2 }, + { "name": "Heavy LRM Carrier (Standard)", "col": 5, "row": 2 } + ] + }, + { + "name": "Opposition", + "bv": 3741, + "units": [ + { "name": "Atlas AS7-D", "col": 2, "row": 0 }, + { "name": "Thanatos TNS-4S", "col": 5, "row": 0 } + ] + } + ] + }, + { + "slug": "sinkhole", + "date": "2026-08-23", + "name": "Sinkhole", + "blurb": "Six mediums around a desert sinkhole. The short way across is the one everybody can see you take.", + "years": { "from": 3025, "to": 3065 }, + "board": { "columns": 8, "rows": 3 }, + "sides": [ + { + "name": "Challenger", + "bv": 3547, + "units": [ + { "name": "Clint CLNT-5U", "col": 1, "row": 2 }, + { "name": "Cougar H", "col": 3, "row": 2 }, + { "name": "Drillson Heavy Hover Tank (Standard)", "col": 5, "row": 2 } + ] + }, + { + "name": "Opposition", + "bv": 3547, + "units": [ + { "name": "Hellspawn HSN-7D", "col": 1, "row": 0 }, + { "name": "Osiris OSR-3D", "col": 3, "row": 0 }, + { "name": "Uziel UZL-3S", "col": 5, "row": 0 } + ] + } + ] + }, + { + "slug": "archipelago", + "date": "2026-08-24", + "name": "Archipelago", + "blurb": "Four light machines and two mediums among islands. Water is a wall for some of these and a road for others.", + "years": { "from": 2479, "to": 3064 }, + "board": { "columns": 8, "rows": 3 }, + "sides": [ + { + "name": "Challenger", + "bv": 2087, + "units": [ + { "name": "Commando COM-7S", "col": 1, "row": 2 }, + { "name": "Razorback RZK-9S", "col": 3, "row": 2 }, + { "name": "Savannah Master Hovercraft (Standard)", "col": 4, "row": 2 }, + { "name": "Stinger STG-3R", "col": 6, "row": 2 } + ] + }, + { + "name": "Opposition", + "bv": 2087, + "units": [ + { "name": "Drillson Heavy Hover Tank (Standard)", "col": 2, "row": 0 }, + { "name": "Osiris OSR-3D", "col": 5, "row": 0 } + ] + } ] } ] diff --git a/web/src/destinations.ts b/web/src/destinations.ts index fe3359d..c370913 100644 --- a/web/src/destinations.ts +++ b/web/src/destinations.ts @@ -192,9 +192,21 @@ export function isAllowed(route: string, signedIn: boolean): boolean { * About. Blog is not one: those are pages the build wrote, and the router * never runs on them. */ -const HASH_ROUTES = new Set([ +/** + * Addresses the app answers that no masthead link points at. + * + * The bar is a curated list of destinations, and the daily-challenge archive + * is not one: it is reached from the card on the front page, which is where + * anybody who cares about it already is. It still needs to be a real address — + * a past challenge is a thing to link to — so it is a route without being a + * destination. + */ +const EXTRA_ROUTES = ["daily"] as const; + +const HASH_ROUTES = new Set([ ...DESTINATIONS.filter((d) => d.href?.startsWith("/#")).map((d) => d.id), ...ABOUT_ROUTES, + ...EXTRA_ROUTES, ]); /** The addresses the app itself is served at. Everything else is a page. */ diff --git a/web/src/router.ts b/web/src/router.ts index 9a5cc25..a0f5398 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -40,6 +40,7 @@ import { takePendingLobbyInvite, } from "./lobby-invite-stash"; import { aboutScreen } from "./screens/about"; +import { dailyArchiveScreen } from "./screens/daily-archive"; import { errorScreen } from "./screens/error"; import { homeScreen } from "./screens/home"; import { lobbyScreen } from "./screens/lobby"; @@ -200,6 +201,14 @@ export async function start(): Promise { return; } + // The archive of past daily challenges. Bundled like the front page, so it + // does not wait on /api/session either — a list of fights that have already + // happened is the same for whoever is reading it. + if (route === "daily") { + render(...dailyArchiveScreen()); + return; + } + // The camo editor runs entirely in the browser and needs no session until it // saves, so it does not wait on /api/session. // diff --git a/web/src/screens/daily-archive.ts b/web/src/screens/daily-archive.ts new file mode 100644 index 0000000..817357a --- /dev/null +++ b/web/src/screens/daily-archive.ts @@ -0,0 +1,99 @@ +/** + * Every daily challenge that has had its day, newest first. + * + * The front page carries one fight; this is the rest of them. Each row says + * the same things the card did on the day — what it was called, the line about + * it, the board, what each side was worth — because somebody arriving here is + * deciding whether to play one, and that is the same decision. + * + * A backlog challenge is playable and does not score. Scoring a fight after + * its day would rank a player who took their time against players who had one + * evening, and there is no honest way to compare those. The row says so rather + * than leaving it to be found out. + * + * Nothing here calls the API. This page is bundled like the front page is, and + * a list of past fights has no reason to go dark when the control plane does. + */ + +import { footer } from "../chrome"; +import { el } from "../dom"; +import { + archive, + boardCanvas, + scenarioHref, + utcDay, + type Challenge, +} from "./daily"; + +const number = new Intl.NumberFormat(); + +/** One past day. */ +function entry(challenge: Challenge, today: string): HTMLElement { + const current = challenge.date === today; + return el("section", { className: "card daily-archive-entry" }, [ + el("div", { className: "daily-head" }, [ + el("p", { className: "daily-eyebrow", textContent: challenge.date }), + el("p", { + className: "daily-soon", + // The one row where playing still counts. Everything below it is a + // backlog fight, and saying which is which here is cheaper than + // explaining it once at the top and hoping it is read. + textContent: current ? "Today — scored" : "Backlog — not scored", + }), + ]), + el("h2", { textContent: challenge.name }), + el("p", { className: "hint daily-blurb", textContent: challenge.blurb }), + el("div", { className: "daily-body" }, [ + el("div", { className: "daily-map-frame" }, [boardCanvas(challenge)]), + el("div", { className: "daily-stats" }, [ + ...challenge.sides.map((side) => + el("div", { className: "daily-stat" }, [ + el("p", { + className: "daily-value", + textContent: number.format(side.bv), + }), + el("p", { + className: "daily-label", + textContent: `${side.name} BV, ${side.units.length} units`, + }), + ]), + ), + el("div", { className: "daily-stat" }, [ + el("p", { + className: "daily-value", + textContent: `${challenge.years.from}–${challenge.years.to}`, + }), + el("p", { className: "daily-label", textContent: "Designs from" }), + ]), + ]), + ]), + el("p", { className: "daily-actions" }, [ + el("a", { + className: "daily-download", + href: scenarioHref(challenge.slug), + download: `${challenge.slug}.mms`, + textContent: "Download for MegaMek", + }), + ]), + ]); +} + +export function dailyArchiveScreen(): Node[] { + const today = utcDay(); + const past = archive(today); + + return [ + el("div", { className: "stack" }, [ + el("h1", { textContent: "Daily challenges" }), + el("p", { + className: "hint", + textContent: + past.length === 0 + ? "Nothing has been set yet." + : "One fight a day, the same for everyone. Today's is scored; the rest are here to play.", + }), + ...past.map((challenge) => entry(challenge, today)), + ]), + footer(), + ]; +} diff --git a/web/src/screens/daily.ts b/web/src/screens/daily.ts index 7e324d5..545c8d7 100644 --- a/web/src/screens/daily.ts +++ b/web/src/screens/daily.ts @@ -1,137 +1,141 @@ /** - * The daily challenge card on the front page. + * The daily challenge card on the front page, and the day's pick behind it. * - * One fight a day, the same for everyone — plan/daily-challenge.md. Nothing - * behind it runs yet: there is no daily anything on the API, no result to - * score and no way in. The card is here first because what it shows is the - * decision worth making early — a challenge you can size up before you commit - * to it, rather than a name and a Play button — and because the front page is - * where somebody who has never signed in finds out this exists. + * One fight a day, the same for everyone — plan/daily-challenge.md. What the + * card shows is what a player needs to size the fight up before committing to + * it: what it is called, a line about it, the board with both forces on it, + * what each side is worth, and how many people have taken it on. * - * So it is drawn from content/daily.json, and it says it is not ready. - * Everything on it is bundled with the page: the front page is served from S3 - * and holds its own copy of everything on it, and a card that went dark - * whenever the control plane did would be the one thing on the page that does. + * Everything except the attempt count comes from content/daily.json, which is + * bundled with the page. The front page is served from S3 and holds its own + * copy of everything on it, so the card is up whenever the bucket is; the + * count is the one live figure, and it is left out rather than allowed to keep + * the card waiting. * - * The map is drawn the way the scenario preview and the camo editor draw - * theirs — MegaMek's own board tile and unit art, composited pixel by pixel — - * so what it shows is the art the match will use rather than an impression of - * it. + * daily.json is generated, not written: `mms index` builds it from the + * scenario files and arena's report of what they are worth. The blurb is the + * scenario's own `Description=` line, so the card and the file a player + * downloads say the same thing. + * + * The map is drawn the way the lobby's force cards and the scenario preview + * draw theirs — MegaMek's own board tile and unit art, composited pixel by + * pixel through paintUnitIcon — so what it shows is the art the match will + * use rather than an impression of it. */ -import battlespaceUrl from "../camo/backgrounds/battlespace.png?url"; -import { applyCamo, loadTile, over, SPRITE_H, SPRITE_W } from "../camo/mech"; -import dailyJson from "../content/daily.json"; -import { ERA_ICONS } from "../content/trivia"; +import { dailyStatus } from "../api"; import { el } from "../dom"; -import atlasUrl from "../scenario/units/atlas.png?url"; -import griffinUrl from "../scenario/units/griffin.png?url"; -import marauderUrl from "../scenario/units/marauder.png?url"; -import shadowhawkUrl from "../scenario/units/shadowhawk.png?url"; -import thunderboltUrl from "../scenario/units/thunderbolt.png?url"; -import warhammerUrl from "../scenario/units/warhammer.png?url"; - -/** A unit on the board: which sprite, and the cell it stands in. */ -type Placement = { sprite: string; col: number; row: number }; - -/** One force: what it fields, what it is painted, and what it is worth. */ -type Side = { +import { SPRITE_H, SPRITE_W } from "../camo/mech"; +import dailyJson from "../content/daily.json"; + +/** A unit on the schematic board: which machine, and the cell it stands in. */ +export type Placement = { name: string; col: number; row: number }; + +/** One force: what it fields and what it is worth. */ +export type Side = { name: string; - /** - * Flat camo, so the two forces read apart at map size. Three channels; - * daily.test.mjs is what says so, because JSON has no tuples. - */ - colour: number[]; - /** Total battle value. */ + /** Total battle value, as MegaMek computes it for these pilots. */ bv: number; units: Placement[]; }; /** - * Today's challenge — except that it is not today's, and there is no today. + * One day's fight. * - * Every string in the file is placeholder (plan/site-copy.md) and every figure - * in it is invented: the battle values are not computed from the units beside - * them and the play count counts nobody. What is real is the shape — a name, a - * line about it, the board, the era, what each side is worth and how many - * people have taken it on — because that shape is what the API replacing this - * file will have to answer with. + * `date` is the UTC day this is the daily challenge, and it is authored: the + * pick is a schedule rather than a computation over the list, so appending an + * eleventh challenge cannot change which fight the sixth of the month was. */ -type Challenge = { +export type Challenge = { + slug: string; + date: string; name: string; blurb: string; - /** A key of ERA_ICONS, which is the closed set of eras with a symbol. */ - era: string; - played: number; + /** Introduction years of the oldest and newest design in the fight. */ + years: { from: number; to: number }; board: { columns: number; rows: number }; sides: Side[]; }; // The annotation is the check: a file that does not fit fails tsc rather than -// the page. daily.test.mjs covers what a type cannot — that the sprites named -// are files that exist, and that the cells are on the board. -const challenge: Challenge = dailyJson; +// the page. +const challenges: Challenge[] = dailyJson.challenges; /** - * Sprite file to art, for the units daily.json places. + * Today in UTC, as `YYYY-MM-DD`. * - * Named by file rather than by catalog unit name, unlike the scenario - * preview's map: this board is a picture of a fight nothing has scheduled, so - * there is no catalog entry behind these and nothing to keep them honest - * against. When there is a real challenge, its units come from the catalog and - * this map goes with the placeholder. + * UTC because "the same fight as everyone else" has to mean the same fight: + * a per-player day would roll over at a different moment for every timezone + * and make the claim untrue. It costs some players a rollover mid-evening. */ -const SPRITE_URLS: Record = { - "atlas.png": atlasUrl, - "griffin.png": griffinUrl, - "marauder.png": marauderUrl, - "shadowhawk.png": shadowhawkUrl, - "thunderbolt.png": thunderboltUrl, - "warhammer.png": warhammerUrl, -}; +export function utcDay(now: Date = new Date()): string { + return now.toISOString().slice(0, 10); +} -/** A flat colour as camo: applyCamo repaints a sprite's greys with it. */ -function flatCamo([r = 0, g = 0, b = 0]: number[]): ImageData { - const camo = new ImageData(SPRITE_W, SPRITE_H); - for (let i = 0; i < camo.data.length; i += 4) { - camo.data[i] = r; - camo.data[i + 1] = g; - camo.data[i + 2] = b; - camo.data[i + 3] = 255; - } - return camo; +/** + * The challenge for a given UTC day: the one scheduled for it, or the most + * recent one before it. + * + * Falling back to the latest past challenge rather than to nothing is what + * keeps the front page from going blank the day the schedule runs out. It is + * a stale card rather than an empty one, and `isCurrent` below is what says + * which it is. + */ +export function challengeFor(day: string): Challenge | null { + const past = challenges.filter((c) => c.date <= day); + if (past.length === 0) return null; + return past.reduce((latest, c) => (c.date > latest.date ? c : latest)); +} + +/** Every challenge whose day has come, most recent first. */ +export function archive(day: string): Challenge[] { + return challenges + .filter((c) => c.date <= day) + .sort((a, b) => (a.date < b.date ? 1 : -1)); +} + +/** Where a challenge's scenario file is served from. */ +export function scenarioHref(slug: string): string { + return `/daily/${slug}.mms`; } /** - * The board: the hex tile repeated across it, then each side's units painted - * in that side's colour and dropped on their cells. + * The board: the hex tile repeated across it, then each side's units dropped + * on their cells in that side's colour. * * Drawn after the card is already on the page, so nothing on the front page * waits for it. A failure leaves the empty board behind — the figures beside * it are the point, and a missing picture is not worth a broken card. + * + * The painter is imported dynamically, and that is not an optimisation to be + * tidied away later: preview.ts names every unit sprite the site has, and + * pulling it into the front page's eager bundle puts a hundred kilobytes in + * front of the hero for the sake of a picture drawn afterwards. + * analytics.test.mjs is what holds that line. */ -function drawMap(canvas: HTMLCanvasElement): void { +function drawMap(canvas: HTMLCanvasElement, challenge: Challenge): void { void (async () => { + const { paintUnitIcon } = await import("../scenario/preview"); + const { loadTile } = await import("../camo/mech"); + const { default: battlespaceUrl } = + await import("../camo/backgrounds/battlespace.png?url"); const ctx = canvas.getContext("2d")!; - const board = await loadTile(battlespaceUrl); + // The empty field first. paintUnitIcon composites a unit onto its own + // tile, so without this the board is only ground where somebody is + // standing and the rest is the card behind it. + const ground = await loadTile(battlespaceUrl); for (let col = 0; col < challenge.board.columns; col++) { for (let row = 0; row < challenge.board.rows; row++) { - ctx.putImageData(board, col * SPRITE_W, row * SPRITE_H); + ctx.putImageData(ground, col * SPRITE_W, row * SPRITE_H); } } - for (const side of challenge.sides) { - const camo = flatCamo(side.colour); + for (const [index, side] of challenge.sides.entries()) { + const camo = flatCamo(SIDE_COLOURS[index] ?? SIDE_COLOURS[0]!); for (const unit of side.units) { - const url = SPRITE_URLS[unit.sprite]; - if (!url) continue; - const sprite = await loadTile(url); - ctx.putImageData( - over(board, applyCamo(sprite, camo)), - unit.col * SPRITE_W, - unit.row * SPRITE_H, - ); + const icon = await paintUnitIcon(unit.name, camo); + ctx.putImageData(icon, unit.col * SPRITE_W, unit.row * SPRITE_H); } } })().catch((error: unknown) => { @@ -139,6 +143,27 @@ function drawMap(canvas: HTMLCanvasElement): void { }); } +/** + * Flat colours so the two forces read apart at map size. Not the player's own + * camo: the card is drawn for everybody, signed in or not, and the same fight + * has to look the same to all of them. + */ +const SIDE_COLOURS: readonly (readonly [number, number, number])[] = [ + [86, 132, 199], + [176, 92, 72], +]; + +function flatCamo([r, g, b]: readonly [number, number, number]): ImageData { + const camo = new ImageData(SPRITE_W, SPRITE_H); + for (let i = 0; i < camo.data.length; i += 4) { + camo.data[i] = r; + camo.data[i + 1] = g; + camo.data[i + 2] = b; + camo.data[i + 3] = 255; + } + return camo; +} + /** One figure beside the map. */ function stat(label: string, value: Node | string): HTMLElement { return el("div", { className: "daily-stat" }, [ @@ -147,59 +172,80 @@ function stat(label: string, value: Node | string): HTMLElement { ]); } -/** - * The era, as its symbol and its name. - * - * The symbol is a mask filled with the text colour rather than an image, the - * way the waiting screen draws the same files: they are white glyphs on alpha, - * and shown as-is one of them disappears into a light-mode card. - */ -function eraStat(): HTMLElement { - const icon = ERA_ICONS[challenge.era]; - const name = el("span", { - className: "daily-era-name", - textContent: challenge.era, - }); - if (!icon) return stat("Era", name); - - const symbol = el("span", { className: "daily-era-icon" }); - symbol.style.setProperty("--era-icon", `url("${icon}")`); - return stat("Era", el("span", { className: "daily-era" }, [symbol, name])); -} - const number = new Intl.NumberFormat(); -export function dailyCard(): HTMLElement { +/** The board picture, sized to the schematic grid. */ +export function boardCanvas(challenge: Challenge): HTMLCanvasElement { const map = el("canvas", { className: "daily-map" }); map.width = challenge.board.columns * SPRITE_W; map.height = challenge.board.rows * SPRITE_H; - // What the picture shows, for anyone who cannot see it. The forces are named - // and counted rather than described hex by hex: the map is a summary, and so - // is this. + // What the picture shows, for anyone who cannot see it. The forces are + // named and counted rather than described hex by hex: the map is a summary, + // and so is this. map.role = "img"; map.ariaLabel = challenge.sides .map((side) => `${side.name}, ${side.units.length} units`) .join("; "); - drawMap(map); + drawMap(map, challenge); + return map; +} + +/** + * The card, for whichever fight today's is. + * + * Returns nothing when the schedule is empty, so an unscheduled site drops the + * card rather than showing an empty one. + */ +export function dailyCard(): HTMLElement | null { + const day = utcDay(); + const challenge = challengeFor(day); + if (!challenge) return null; + + const played = el("p", { className: "daily-value", textContent: "—" }); + // The one figure that is not in the bundle. A card that waited for it would + // be a card that goes blank when the control plane does. + void dailyStatus(challenge.slug) + .then((status) => { + played.textContent = number.format(status.attempts); + }) + .catch(() => { + /* the count is the least of what is on this card */ + }); return el("section", { className: "card daily" }, [ el("div", { className: "daily-head" }, [ el("p", { className: "daily-eyebrow", textContent: "Daily challenge" }), - // Not a disabled button: there is nothing to press, and a control that - // exists only to refuse is worse than no control at all. - el("p", { className: "daily-soon", textContent: "Not ready yet" }), + el("p", { + className: "daily-soon", + textContent: + challenge.date === day ? "Today" : `Since ${challenge.date}`, + }), ]), el("h2", { textContent: challenge.name }), el("p", { className: "hint daily-blurb", textContent: challenge.blurb }), el("div", { className: "daily-body" }, [ - el("div", { className: "daily-map-frame" }, [map]), + el("div", { className: "daily-map-frame" }, [boardCanvas(challenge)]), el("div", { className: "daily-stats" }, [ - eraStat(), ...challenge.sides.map((side) => stat(`${side.name} BV`, number.format(side.bv)), ), - stat("Played today", number.format(challenge.played)), + stat("Designs from", `${challenge.years.from}–${challenge.years.to}`), + el("div", { className: "daily-stat" }, [ + played, + el("p", { className: "daily-label", textContent: "Attempts" }), + ]), ]), ]), + el("p", { className: "daily-actions" }, [ + el("a", { + className: "daily-download", + href: scenarioHref(challenge.slug), + // download rather than navigate: a .mms is text, and a browser that + // decides to render it instead of saving it has eaten the link. + download: `${challenge.slug}.mms`, + textContent: "Download for MegaMek", + }), + el("a", { href: "/#daily", textContent: "Past challenges" }), + ]), ]); } diff --git a/web/src/screens/home.ts b/web/src/screens/home.ts index 37054c3..3a0b35d 100644 --- a/web/src/screens/home.ts +++ b/web/src/screens/home.ts @@ -93,13 +93,19 @@ export function homeScreen(state: HomePanel): Node[] { ]), // Under the hero and above the facts, in all three states: what is on // today is the same question whether or not anyone is signed in, and a - // stranger who has never heard of this is exactly who it is for. - dailyCard(), + // stranger who has never heard of this is exactly who it is for. Dropped + // entirely when nothing is scheduled, rather than shown empty. + ...maybe(dailyCard()), factsStrip(), footer(), ]; } +/** A node, or nothing at all, as a list to spread. */ +function maybe(node: Node | null): Node[] { + return node ? [node] : []; +} + function factsStrip(): HTMLElement { const fact = (title: string, body: (Node | string)[]): HTMLElement => el("section", { className: "fact" }, [ diff --git a/web/src/styles.css b/web/src/styles.css index 0550e50..eb8d1ad 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1115,9 +1115,8 @@ body:has(.hero) { One fight a day, on the front page, whoever is looking at it. The board on the left and the figures on the right down to a phone, where they stack. - Nothing here is pressable, because nothing behind it runs yet: the card is - marked "not ready yet" in words rather than dimmed as a whole, which would - make the figures on it harder to read for no gain. */ + The same blocks dress the archive at #daily, one card per past day, so a + challenge looks the same wherever it is read. */ .daily-head { display: flex; @@ -1220,25 +1219,34 @@ body:has(.hero) { text-transform: uppercase; } -/* The era reads as its symbol and its name together, at the size the figures - beside it are set in. */ -.daily-era { +/* The row under the card: the scenario file, and the way to the archive. + A download is the one thing here that leaves the site, so it is the one + thing styled as a button. */ +.daily-actions { display: flex; + flex-wrap: wrap; align-items: center; - gap: 0.4rem; + gap: 1rem; + margin: 1rem 0 0; + font-size: 0.9rem; } -/* Painted in the text colour, like .trivia-era-icon: the source images are - white glyphs on alpha, and one shown as-is vanishes into a light card. */ -.daily-era-icon { - width: 1.5rem; - height: 1.5rem; - background-color: currentcolor; - mask: var(--era-icon) center / contain no-repeat; +.daily-download { + border: 1px solid var(--rule); + border-radius: 0.35rem; + padding: 0.4rem 0.9rem; + text-decoration: none; } -.daily-era-name { - font-size: 0.95rem; +.daily-download:hover, +.daily-download:focus-visible { + border-color: var(--brand); +} + +/* The archive is a stack of the same card, so the only thing it needs of its + own is the space between them. */ +.daily-archive-entry + .daily-archive-entry { + margin-top: 1rem; } /* --- the three facts ----------------------------------------------------- -- 2.51.2