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 -----------------------------------------------------