diff --git a/crates/mms/src/bin/mms.rs b/crates/mms/src/bin/mms.rs index a62e26e..4af02fb 100644 --- a/crates/mms/src/bin/mms.rs +++ b/crates/mms/src/bin/mms.rs @@ -26,7 +26,7 @@ use std::collections::BTreeMap; use std::path::Path; use std::process::ExitCode; -use headquarters_mms::v1::{Quirk, Unit}; +use headquarters_mms::v1::Quirk; use headquarters_mms::{ScenarioV1, Version, detect_version, parse}; fn main() -> ExitCode { @@ -148,17 +148,21 @@ struct Force { struct UnitRow { chassis: String, + /// Where this unit sits in its faction's list, as the file wrote it. + /// + /// The card orders a force left to right along the board, but the report + /// lists it in file order, so the two are only lined up by the position + /// each unit started in. Matching by name instead would be wrong the + /// moment a force fields two of the same design with different crews - + /// which is a normal thing for a scenario to do, and BV 2.0 multiplies by + /// the crew. + index: usize, /// One-based board position, absent for a unit the file never placed. position: Option<(i32, i32)>, /// The facing MegaMek will actually apply, or None for north. facing: Option, } -/// The chassis and model, as the report's unit names are written. -fn short_name(full: &str) -> String { - full.split(" (").next().unwrap_or(full).to_string() -} - fn forces_from_v1(scenario: &ScenarioV1) -> Vec { scenario .factions @@ -170,8 +174,10 @@ fn forces_from_v1(scenario: &ScenarioV1) -> Vec { units: faction .units .iter() - .map(|unit| UnitRow { + .enumerate() + .map(|(index, unit)| UnitRow { chassis: unit.chassis.clone(), + index, position: unit.position, facing: unit.facing_applied(), }) @@ -191,8 +197,10 @@ fn forces_from_v2(scenario: &headquarters_mms::v2::ScenarioV2) -> Vec { units: faction .units .iter() - .map(|unit| UnitRow { + .enumerate() + .map(|(index, unit)| UnitRow { chassis: unit.name.clone(), + index, position: unit.at, // The number the file writes, back as the letter the card // draws. A facing MegaMek will not act on is None here for @@ -326,11 +334,8 @@ fn entry( // where everybody stood. None => edge_row(&faction.location), }; - let year = measured - .years - .get(&short_name(&unit.chassis)) - .copied() - .unwrap_or_default(); + let reported = measured.units.get(unit.index); + let year = reported.map_or(0, |u| u.year); if year > 0 { years = Some(match years { None => (year, year), @@ -341,8 +346,15 @@ fn entry( // look the way the fight has it looking. A token MegaMek ignores // faces north, and so does a unit that was never deployed. let facing = unit.facing.clone().unwrap_or_else(|| "N".to_string()); + // What this machine is worth with this crew in it, so a force card + // can show a figure per unit and not just a total. Left out rather + // than zeroed when the report has nothing to say. + let bv = match reported.map(|u| u.bv).filter(|bv| *bv > 0) { + Some(bv) => format!(", \"bv\": {bv}"), + None => String::new(), + }; units.push(format!( - " {{ \"name\": {}, \"row\": {row}, \"facing\": {} }}", + " {{ \"name\": {}, \"row\": {row}, \"facing\": {}{bv} }}", json_string(&unit.chassis), json_string(&facing) )); @@ -514,7 +526,18 @@ struct Measured { /// Absent in a report written before arena measured them. tons: Option, cost: Option, - years: BTreeMap, + /// The faction's units, in the order the scenario file lists them. + units: Vec, +} + +/// One machine as MegaMek weighed it, in the fight it was weighed in. +/// +/// `bv` belongs to the machine *and its crew* - BV 2.0 multiplies by gunnery +/// and piloting - so it is carried per scenario here rather than in the shared +/// unit table scripts/unit-stats.py writes. +struct MeasuredUnit { + bv: i64, + year: i32, } impl Report { @@ -536,20 +559,22 @@ impl Report { boards.insert(file.clone(), h as i32); } 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); - } - } + let units = faction["units"] + .as_array() + .into_iter() + .flatten() + .map(|unit| MeasuredUnit { + bv: unit["bv"].as_i64().unwrap_or_default(), + year: unit["year"].as_i64().unwrap_or_default() as i32, + }) + .collect(); out.push(Measured { file: file.clone(), faction: faction["name"].as_str().unwrap_or_default().to_string(), bv: faction["bv"].as_i64().unwrap_or_default(), tons: faction["tons"].as_f64(), cost: faction["cost"].as_i64(), - years, + units, }); } } @@ -604,19 +629,6 @@ fn json_string(value: &str) -> String { out } -trait ShortName { - fn chassis_short(&self) -> String; -} - -impl ShortName for 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)", "") - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/scripts/unit-stats.py b/scripts/unit-stats.py new file mode 100755 index 0000000..e05339d --- /dev/null +++ b/scripts/unit-stats.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Collect what each machine weighs and costs, for every lobby view to read. + + scripts/unit-stats.py --out web/src/content/unit-stats.json + scripts/unit-stats.py --out web/src/content/unit-stats.json --check + +A force card wants to say what each machine weighs and what the force adds up +to. None of that is in a unit file in any form this repo can read, so this +reads what real MegaMek already reported: arena's `ScenarioReport` loads a +scenario for real, and its output for the daily challenges is committed beside +the scenarios. Nothing here computes anything. + +Weight, price and introduction year only. Battle value is deliberately not in +this file, and the reason is the whole shape of it: BV 2.0 multiplies by the +crew's gunnery and piloting, so the same machine is worth 359 with one pilot in +it and 323 with another - Stinger STG-3R is exactly this, across two dailies. +A table keyed by the machine cannot hold a number that belongs to the machine +*and its pilot*, so per-unit BV rides on the scenario instead, in daily.json. +What is here is true of the design wherever it turns up. + +Keyed by the full designation - "Clint CLNT-5U", chassis and model - because +that is what a scenario file names and what the catalog names. + +Coverage is partial on purpose. The dailies are reported; the hand-written +catalog in services/api/src/matches/scenarios.rs is not, and nine of the +thirty-four machines it fields have never appeared in a daily. Those get no +entry, the card shows a dash where a figure would go, and nothing is invented +to fill the hole. Reporting the catalog too is an arena run away - see +plan/scenarios.md - and when it happens the file lands here and the cards fill +in with no change to the site. +""" + +import argparse +import json +import pathlib +import sys + +REPORTS = ["services/api/src/matches/daily/report.json"] + + +def collect(root: pathlib.Path) -> dict[str, dict[str, int]]: + """Every unit any report names, with what that report said it was worth.""" + units: dict[str, dict[str, int]] = {} + for name in REPORTS: + path = root / name + if not path.exists(): + continue + for scenario in json.loads(path.read_text()): + for faction in scenario.get("factions", []): + for unit in faction.get("units", []): + designation = unit.get("name") + if not designation: + continue + entry = { + "tons": unit["tons"], + "cost": unit["cost"], + "year": unit["year"], + } + # A machine fielded by two scenarios is reported twice. + # These three are the design's own, so the two agree - and + # if they ever do not, the disagreement is a bug worth + # failing on rather than a value worth picking between. + # (BV would not agree, which is why it is not here.) + if designation in units and units[designation] != entry: + raise SystemExit( + f"{designation}: two reports disagree, " + f"{units[designation]} vs {entry}" + ) + units[designation] = entry + return units + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", required=True, type=pathlib.Path) + parser.add_argument( + "--check", + action="store_true", + help="write nothing; fail if the committed file is not what this run " + "would produce", + ) + args = parser.parse_args() + + root = pathlib.Path(__file__).resolve().parent.parent + units = collect(root) + text = json.dumps({"units": dict(sorted(units.items()))}, indent=1) + "\n" + + if args.check: + if not args.out.exists(): + print(f"{args.out}: missing; run scripts/unit-stats.py", file=sys.stderr) + return 1 + if args.out.read_text() != text: + print( + f"{args.out}: stale; re-run scripts/unit-stats.py", + file=sys.stderr, + ) + return 1 + return 0 + + args.out.write_text(text) + print(f"{args.out}: {len(units)} machines.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/web/src/content/daily.json b/web/src/content/daily.json index e438d6b..c9705bf 100644 --- a/web/src/content/daily.json +++ b/web/src/content/daily.json @@ -2,538 +2,439 @@ "challenges": [ { "slug": "parity", - "map": { - "cols": 2, - "boards": [ - "Map Set 2/16x17 BattleTech.board", - "Map Set 2/16x17 Scattered Woods.board" - ] - }, + "map": { "cols": 2, "boards": ["Map Set 2/16x17 BattleTech.board", "Map Set 2/16x17 Scattered Woods.board"] }, "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", - "team": 1, - "start": "S", - "bv": 6633, - "tons": 250, - "cost": 31036146, - "units": [ - { "name": "Archer ARC-2R", "row": 2, "facing": "N" }, - { "name": "Archer ARC-4M", "row": 2, "facing": "N" }, - { "name": "Argus AGS-4D", "row": 2, "facing": "N" }, - { - "name": "Hellhound (Conjurer) (Standard)", - "row": 2, - "facing": "N" - } - ] - }, - { - "name": "OPFOR", - "team": 2, - "start": "N", - "bv": 6633, - "tons": 290, - "cost": 52712660, - "units": [ - { "name": "Atlas AS7-D", "row": 0, "facing": "S" }, - { "name": "Bushwacker BSW-L1", "row": 0, "facing": "S" }, - { "name": "Enfield END-6J", "row": 0, "facing": "S" }, - { "name": "Templar TLR1-OB", "row": 0, "facing": "S" } - ] - } + { + "name": "Challenger", + "team": 1, + "start": "S", + "bv": 6633, + "tons": 250, + "cost": 31036146, + "units": [ + { "name": "Archer ARC-2R", "row": 2, "facing": "N", "bv": 1477 }, + { "name": "Archer ARC-4M", "row": 2, "facing": "N", "bv": 1705 }, + { "name": "Argus AGS-4D", "row": 2, "facing": "N", "bv": 1638 }, + { "name": "Hellhound (Conjurer) (Standard)", "row": 2, "facing": "N", "bv": 1813 } + ] + }, + { + "name": "OPFOR", + "team": 2, + "start": "N", + "bv": 6633, + "tons": 290, + "cost": 52712660, + "units": [ + { "name": "Atlas AS7-D", "row": 0, "facing": "S", "bv": 1897 }, + { "name": "Bushwacker BSW-L1", "row": 0, "facing": "S", "bv": 1513 }, + { "name": "Enfield END-6J", "row": 0, "facing": "S", "bv": 1417 }, + { "name": "Templar TLR1-OB", "row": 0, "facing": "S", "bv": 1806 } + ] + } ] }, { "slug": "bug-company", - "overrides": { - "rulesOn": ["individual_initiative"], - "rulesOff": [], - "ai": [ - { "knob": "hyperAggression", "value": "9" }, - { "knob": "selfPreservation", "value": "2" }, - { "knob": "herdMentality", "value": "7" }, - { "knob": "bravery", "value": "5" } - ] - }, - "map": { - "cols": 2, - "boards": [ - "AGoAC Maps/16x17 Grassland 2.board", - "AGoAC Maps/16x17 Grassland 3.board" - ] - }, + "overrides": { "rulesOn": ["individual_initiative"], "rulesOff": [], "ai": [{ "knob": "hyperAggression", "value": "9" }, { "knob": "selfPreservation", "value": "2" }, { "knob": "herdMentality", "value": "7" }, { "knob": "bravery", "value": "5" }] }, + "map": { "cols": 2, "boards": ["AGoAC Maps/16x17 Grassland 2.board", "AGoAC Maps/16x17 Grassland 3.board"] }, "date": "2026-08-16", "name": "Bug Company", "blurb": "One assault, surrounded. Three pirate lances close from three directions at once, and the Alpha Wolf can only face one of them at a time.", "years": { "from": 2471, "to": 3150 }, "board": { "columns": 8, "rows": 3 }, "sides": [ - { - "name": "Challenger", - "team": 1, - "start": "CTR", - "bv": 5771, - "tons": 90, - "cost": 40261000, - "units": [{ "name": "Alpha Wolf A", "row": 1, "facing": "N" }] - }, - { - "name": "Talon Lance", - "team": 2, - "start": "N", - "bv": 1555, - "tons": 80, - "cost": 6333680, - "units": [ - { "name": "Locust LCT-1S", "row": 0, "facing": "S" }, - { "name": "Locust LCT-1V", "row": 0, "facing": "S" }, - { "name": "Stinger STG-3G", "row": 0, "facing": "S" }, - { "name": "Stinger STG-3R", "row": 0, "facing": "S" } - ] - }, - { - "name": "Hammer Lance", - "team": 2, - "start": "SW", - "bv": 2208, - "tons": 100, - "cost": 10657460, - "units": [ - { "name": "Wasp WSP-1W", "row": 2, "facing": "NE" }, - { "name": "Wasp WSP-1A", "row": 2, "facing": "NE" }, - { "name": "Firefly FFL-3PP", "row": 2, "facing": "NE" }, - { "name": "Firefly FFL-3PP3", "row": 2, "facing": "NE" } - ] - }, - { - "name": "Anvil Lance", - "team": 2, - "start": "SE", - "bv": 2008, - "tons": 120, - "cost": 10496263, - "units": [ - { "name": "Spider SDR-5D", "row": 2, "facing": "NW" }, - { "name": "Spider SDR-5K", "row": 2, "facing": "NW" }, - { "name": "Flea FLE-4", "row": 2, "facing": "NW" }, - { "name": "Cicada CDA-3C", "row": 2, "facing": "NW" } - ] - } + { + "name": "Challenger", + "team": 1, + "start": "CTR", + "bv": 5771, + "tons": 90, + "cost": 40261000, + "units": [ + { "name": "Alpha Wolf A", "row": 1, "facing": "N", "bv": 5771 } + ] + }, + { + "name": "Talon Lance", + "team": 2, + "start": "N", + "bv": 1555, + "tons": 80, + "cost": 6333680, + "units": [ + { "name": "Locust LCT-1S", "row": 0, "facing": "S", "bv": 396 }, + { "name": "Locust LCT-1V", "row": 0, "facing": "S", "bv": 389 }, + { "name": "Stinger STG-3G", "row": 0, "facing": "S", "bv": 447 }, + { "name": "Stinger STG-3R", "row": 0, "facing": "S", "bv": 323 } + ] + }, + { + "name": "Hammer Lance", + "team": 2, + "start": "SW", + "bv": 2208, + "tons": 100, + "cost": 10657460, + "units": [ + { "name": "Wasp WSP-1W", "row": 2, "facing": "NE", "bv": 336 }, + { "name": "Wasp WSP-1A", "row": 2, "facing": "NE", "bv": 346 }, + { "name": "Firefly FFL-3PP", "row": 2, "facing": "NE", "bv": 799 }, + { "name": "Firefly FFL-3PP3", "row": 2, "facing": "NE", "bv": 727 } + ] + }, + { + "name": "Anvil Lance", + "team": 2, + "start": "SE", + "bv": 2008, + "tons": 120, + "cost": 10496263, + "units": [ + { "name": "Spider SDR-5D", "row": 2, "facing": "NW", "bv": 472 }, + { "name": "Spider SDR-5K", "row": 2, "facing": "NW", "bv": 453 }, + { "name": "Flea FLE-4", "row": 2, "facing": "NW", "bv": 389 }, + { "name": "Cicada CDA-3C", "row": 2, "facing": "NW", "bv": 694 } + ] + } ] }, { "slug": "two-against-the-star", - "map": { - "cols": 2, - "boards": [ - "Map Set 4/16x17 Heavy Forest 1.board", - "Map Set 4/16x17 Heavy Forest 2.board" - ] - }, + "map": { "cols": 2, "boards": ["Map Set 4/16x17 Heavy Forest 1.board", "Map Set 4/16x17 Heavy Forest 2.board"] }, "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", - "team": 1, - "start": "S", - "bv": 5065, - "tons": 200, - "cost": 22654400, - "units": [ - { "name": "Clint CLNT-5U", "row": 2, "facing": "N" }, - { "name": "Crusader CRD-3R", "row": 2, "facing": "N" }, - { "name": "Osiris OSR-3D", "row": 2, "facing": "N" }, - { "name": "Thunderbolt TDR-5S", "row": 2, "facing": "N" } - ] - }, - { - "name": "OPFOR", - "team": 2, - "start": "N", - "bv": 5065, - "tons": 120, - "cost": 32392833, - "units": [ - { "name": "Black Hawk (Nova) A", "row": 0, "facing": "S" }, - { "name": "Thor (Summoner) D", "row": 0, "facing": "S" } - ] - } + { + "name": "Challenger", + "team": 1, + "start": "S", + "bv": 5065, + "tons": 200, + "cost": 22654400, + "units": [ + { "name": "Clint CLNT-5U", "row": 2, "facing": "N", "bv": 1275 }, + { "name": "Crusader CRD-3R", "row": 2, "facing": "N", "bv": 1317 }, + { "name": "Osiris OSR-3D", "row": 2, "facing": "N", "bv": 1138 }, + { "name": "Thunderbolt TDR-5S", "row": 2, "facing": "N", "bv": 1335 } + ] + }, + { + "name": "OPFOR", + "team": 2, + "start": "N", + "bv": 5065, + "tons": 120, + "cost": 32392833, + "units": [ + { "name": "Black Hawk (Nova) A", "row": 0, "facing": "S", "bv": 2422 }, + { "name": "Thor (Summoner) D", "row": 0, "facing": "S", "bv": 2643 } + ] + } ] }, { "slug": "downtown-arbitration", - "map": { - "cols": 2, - "boards": [ - "Map Set 6/16x17 City (Downtown).board", - "Map Set 6/16x17 City (Residential).board" - ] - }, + "map": { "cols": 2, "boards": ["Map Set 6/16x17 City (Downtown).board", "Map Set 6/16x17 City (Residential).board"] }, "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", - "team": 1, - "start": "S", - "bv": 4968, - "tons": 190, - "cost": 21471437, - "units": [ - { "name": "Clint CLNT-5U", "row": 2, "facing": "N" }, - { "name": "Crusader CRD-3R", "row": 2, "facing": "N" }, - { "name": "Griffin GRF-1N", "row": 2, "facing": "N" }, - { "name": "Javelin JVN-11D", "row": 2, "facing": "N" } - ] - }, - { - "name": "OPFOR", - "team": 2, - "start": "N", - "bv": 4968, - "tons": 220, - "cost": 38495506, - "units": [ - { "name": "Hellspawn HSN-7D", "row": 0, "facing": "S" }, - { "name": "Tessen TSN-1C", "row": 0, "facing": "S" }, - { "name": "Warhammer WHM-6R", "row": 0, "facing": "S" }, - { "name": "Wraith TR1", "row": 0, "facing": "S" } - ] - } + { + "name": "Challenger", + "team": 1, + "start": "S", + "bv": 4968, + "tons": 190, + "cost": 21471437, + "units": [ + { "name": "Clint CLNT-5U", "row": 2, "facing": "N", "bv": 1275 }, + { "name": "Crusader CRD-3R", "row": 2, "facing": "N", "bv": 1317 }, + { "name": "Griffin GRF-1N", "row": 2, "facing": "N", "bv": 1272 }, + { "name": "Javelin JVN-11D", "row": 2, "facing": "N", "bv": 1104 } + ] + }, + { + "name": "OPFOR", + "team": 2, + "start": "N", + "bv": 4968, + "tons": 220, + "cost": 38495506, + "units": [ + { "name": "Hellspawn HSN-7D", "row": 0, "facing": "S", "bv": 1220 }, + { "name": "Tessen TSN-1C", "row": 0, "facing": "S", "bv": 1162 }, + { "name": "Warhammer WHM-6R", "row": 0, "facing": "S", "bv": 1299 }, + { "name": "Wraith TR1", "row": 0, "facing": "S", "bv": 1287 } + ] + } ] }, { "slug": "the-long-walk", - "map": { - "cols": 2, - "boards": [ - "Map Set 5/16x17 Moonscape 1.board", - "Map Set 5/16x17 Moonscape 2.board" - ] - }, + "map": { "cols": 2, "boards": ["Map Set 5/16x17 Moonscape 1.board", "Map Set 5/16x17 Moonscape 2.board"] }, "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", - "team": 1, - "start": "S", - "bv": 5079, - "tons": 240, - "cost": 23363246, - "units": [ - { "name": "Archer ARC-2R", "row": 2, "facing": "N" }, - { "name": "Archer ARC-4M", "row": 2, "facing": "N" }, - { "name": "Atlas AS7-D", "row": 2, "facing": "N" } - ] - }, - { - "name": "OPFOR", - "team": 2, - "start": "N", - "bv": 5079, - "tons": 230, - "cost": 42589250, - "units": [ - { "name": "Marauder MAD-5S", "row": 0, "facing": "S" }, - { "name": "Thanatos TNS-4S", "row": 0, "facing": "S" }, - { "name": "Zeus ZEU-6T", "row": 0, "facing": "S" } - ] - } + { + "name": "Challenger", + "team": 1, + "start": "S", + "bv": 5079, + "tons": 240, + "cost": 23363246, + "units": [ + { "name": "Archer ARC-2R", "row": 2, "facing": "N", "bv": 1477 }, + { "name": "Archer ARC-4M", "row": 2, "facing": "N", "bv": 1705 }, + { "name": "Atlas AS7-D", "row": 2, "facing": "N", "bv": 1897 } + ] + }, + { + "name": "OPFOR", + "team": 2, + "start": "N", + "bv": 5079, + "tons": 230, + "cost": 42589250, + "units": [ + { "name": "Marauder MAD-5S", "row": 0, "facing": "S", "bv": 1799 }, + { "name": "Thanatos TNS-4S", "row": 0, "facing": "S", "bv": 1844 }, + { "name": "Zeus ZEU-6T", "row": 0, "facing": "S", "bv": 1436 } + ] + } ] }, { "slug": "tracks-and-treads", - "map": { - "cols": 2, - "boards": [ - "Map Set 7/16x17 Coast 1.board", - "Map Set 7/16x17 Coast 2.board" - ] - }, + "map": { "cols": 2, "boards": ["Map Set 7/16x17 Coast 1.board", "Map Set 7/16x17 Coast 2.board"] }, "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", - "team": 1, - "start": "S", - "bv": 2327, - "tons": 140, - "cost": 5621667, - "units": [ - { - "name": "Drillson Heavy Hover Tank (Standard)", - "row": 2, - "facing": "N" - }, - { "name": "Heavy LRM Carrier (Standard)", "row": 2, "facing": "N" }, - { - "name": "Savannah Master Hovercraft (Standard)", - "row": 2, - "facing": "N" - }, - { - "name": "Savannah Master Hovercraft (Standard)", - "row": 2, - "facing": "N" - } - ] - }, - { - "name": "OPFOR", - "team": 2, - "start": "N", - "bv": 2327, - "tons": 80, - "cost": 15014300, - "units": [ - { "name": "Osiris OSR-3D", "row": 0, "facing": "S" }, - { "name": "Uziel UZL-3S", "row": 0, "facing": "S" } - ] - } + { + "name": "Challenger", + "team": 1, + "start": "S", + "bv": 2327, + "tons": 140, + "cost": 5621667, + "units": [ + { "name": "Drillson Heavy Hover Tank (Standard)", "row": 2, "facing": "N", "bv": 949 }, + { "name": "Heavy LRM Carrier (Standard)", "row": 2, "facing": "N", "bv": 948 }, + { "name": "Savannah Master Hovercraft (Standard)", "row": 2, "facing": "N", "bv": 215 }, + { "name": "Savannah Master Hovercraft (Standard)", "row": 2, "facing": "N", "bv": 215 } + ] + }, + { + "name": "OPFOR", + "team": 2, + "start": "N", + "bv": 2327, + "tons": 80, + "cost": 15014300, + "units": [ + { "name": "Osiris OSR-3D", "row": 0, "facing": "S", "bv": 1138 }, + { "name": "Uziel UZL-3S", "row": 0, "facing": "S", "bv": 1189 } + ] + } ] }, { "slug": "one-of-ours", - "map": { - "cols": 2, - "boards": [ - "Map Set 3/16x17 Rolling Hills 1.board", - "Map Set 3/16x17 Rolling Hills 2.board" - ] - }, + "map": { "cols": 2, "boards": ["Map Set 3/16x17 Rolling Hills 1.board", "Map Set 3/16x17 Rolling Hills 2.board"] }, "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", - "team": 1, - "start": "S", - "bv": 3132, - "tons": 95, - "cost": 28607719, - "units": [{ "name": "Turkina D", "row": 2, "facing": "N" }] - }, - { - "name": "OPFOR", - "team": 2, - "start": "N", - "bv": 3142, - "tons": 140, - "cost": 15519704, - "units": [ - { "name": "Hussar HSR-500-D", "row": 0, "facing": "S" }, - { "name": "Shadow Hawk SHD-2H", "row": 0, "facing": "S" }, - { "name": "Wolverine WVR-6R", "row": 0, "facing": "S" } - ] - } + { + "name": "Challenger", + "team": 1, + "start": "S", + "bv": 3132, + "tons": 95, + "cost": 28607719, + "units": [ + { "name": "Turkina D", "row": 2, "facing": "N", "bv": 3132 } + ] + }, + { + "name": "OPFOR", + "team": 2, + "start": "N", + "bv": 3142, + "tons": 140, + "cost": 15519704, + "units": [ + { "name": "Hussar HSR-500-D", "row": 0, "facing": "S", "bv": 977 }, + { "name": "Shadow Hawk SHD-2H", "row": 0, "facing": "S", "bv": 1064 }, + { "name": "Wolverine WVR-6R", "row": 0, "facing": "S", "bv": 1101 } + ] + } ] }, { "slug": "indirect-fire", - "map": { - "cols": 2, - "boards": [ - "Map Set 4/16x17 Large Lakes 1.board", - "Map Set 4/16x17 Large Lakes 2.board" - ] - }, + "map": { "cols": 2, "boards": ["Map Set 4/16x17 Large Lakes 1.board", "Map Set 4/16x17 Large Lakes 2.board"] }, "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", - "team": 1, - "start": "S", - "bv": 3742, - "tons": 215, - "cost": 15010983, - "units": [ - { "name": "Archer ARC-2R", "row": 2, "facing": "N" }, - { "name": "Crusader CRD-3R", "row": 2, "facing": "N" }, - { "name": "Heavy LRM Carrier (Standard)", "row": 2, "facing": "N" } - ] - }, - { - "name": "OPFOR", - "team": 2, - "start": "N", - "bv": 3741, - "tons": 175, - "cost": 28965250, - "units": [ - { "name": "Atlas AS7-D", "row": 0, "facing": "S" }, - { "name": "Thanatos TNS-4S", "row": 0, "facing": "S" } - ] - } + { + "name": "Challenger", + "team": 1, + "start": "S", + "bv": 3742, + "tons": 215, + "cost": 15010983, + "units": [ + { "name": "Archer ARC-2R", "row": 2, "facing": "N", "bv": 1477 }, + { "name": "Crusader CRD-3R", "row": 2, "facing": "N", "bv": 1317 }, + { "name": "Heavy LRM Carrier (Standard)", "row": 2, "facing": "N", "bv": 948 } + ] + }, + { + "name": "OPFOR", + "team": 2, + "start": "N", + "bv": 3741, + "tons": 175, + "cost": 28965250, + "units": [ + { "name": "Atlas AS7-D", "row": 0, "facing": "S", "bv": 1897 }, + { "name": "Thanatos TNS-4S", "row": 0, "facing": "S", "bv": 1844 } + ] + } ] }, { "slug": "sinkhole", - "map": { - "cols": 2, - "boards": [ - "Map Set 3/16x17 Desert Sinkhole 1.board", - "Map Set 3/16x17 Desert Sinkhole 2.board" - ] - }, + "map": { "cols": 2, "boards": ["Map Set 3/16x17 Desert Sinkhole 1.board", "Map Set 3/16x17 Desert Sinkhole 2.board"] }, "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", - "team": 1, - "start": "S", - "bv": 3547, - "tons": 125, - "cost": 15225435, - "units": [ - { "name": "Clint CLNT-5U", "row": 2, "facing": "N" }, - { "name": "Cougar H", "row": 2, "facing": "N" }, - { - "name": "Drillson Heavy Hover Tank (Standard)", - "row": 2, - "facing": "N" - } - ] - }, - { - "name": "OPFOR", - "team": 2, - "start": "N", - "bv": 3547, - "tons": 125, - "cost": 23389500, - "units": [ - { "name": "Hellspawn HSN-7D", "row": 0, "facing": "S" }, - { "name": "Osiris OSR-3D", "row": 0, "facing": "S" }, - { "name": "Uziel UZL-3S", "row": 0, "facing": "S" } - ] - } + { + "name": "Challenger", + "team": 1, + "start": "S", + "bv": 3547, + "tons": 125, + "cost": 15225435, + "units": [ + { "name": "Clint CLNT-5U", "row": 2, "facing": "N", "bv": 1275 }, + { "name": "Cougar H", "row": 2, "facing": "N", "bv": 1323 }, + { "name": "Drillson Heavy Hover Tank (Standard)", "row": 2, "facing": "N", "bv": 949 } + ] + }, + { + "name": "OPFOR", + "team": 2, + "start": "N", + "bv": 3547, + "tons": 125, + "cost": 23389500, + "units": [ + { "name": "Hellspawn HSN-7D", "row": 0, "facing": "S", "bv": 1220 }, + { "name": "Osiris OSR-3D", "row": 0, "facing": "S", "bv": 1138 }, + { "name": "Uziel UZL-3S", "row": 0, "facing": "S", "bv": 1189 } + ] + } ] }, { "slug": "archipelago", - "map": { - "cols": 2, - "boards": [ - "Map Set 7/16x17 Archipelago 1.board", - "Map Set 7/16x17 Archipelago 2.board" - ] - }, + "map": { "cols": 2, "boards": ["Map Set 7/16x17 Archipelago 1.board", "Map Set 7/16x17 Archipelago 2.board"] }, "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", - "team": 1, - "start": "S", - "bv": 2087, - "tons": 80, - "cost": 8207457, - "units": [ - { "name": "Commando COM-7S", "row": 2, "facing": "N" }, - { "name": "Razorback RZK-9S", "row": 2, "facing": "N" }, - { - "name": "Savannah Master Hovercraft (Standard)", - "row": 2, - "facing": "N" - }, - { "name": "Stinger STG-3R", "row": 2, "facing": "N" } - ] - }, - { - "name": "OPFOR", - "team": 2, - "start": "N", - "bv": 2087, - "tons": 80, - "cost": 7728883, - "units": [ - { - "name": "Drillson Heavy Hover Tank (Standard)", - "row": 0, - "facing": "S" - }, - { "name": "Osiris OSR-3D", "row": 0, "facing": "S" } - ] - } + { + "name": "Challenger", + "team": 1, + "start": "S", + "bv": 2087, + "tons": 80, + "cost": 8207457, + "units": [ + { "name": "Commando COM-7S", "row": 2, "facing": "N", "bv": 658 }, + { "name": "Razorback RZK-9S", "row": 2, "facing": "N", "bv": 855 }, + { "name": "Savannah Master Hovercraft (Standard)", "row": 2, "facing": "N", "bv": 215 }, + { "name": "Stinger STG-3R", "row": 2, "facing": "N", "bv": 359 } + ] + }, + { + "name": "OPFOR", + "team": 2, + "start": "N", + "bv": 2087, + "tons": 80, + "cost": 7728883, + "units": [ + { "name": "Drillson Heavy Hover Tank (Standard)", "row": 0, "facing": "S", "bv": 949 }, + { "name": "Osiris OSR-3D", "row": 0, "facing": "S", "bv": 1138 } + ] + } ] }, { "slug": "scrapyard-arithmetic", - "map": { - "cols": 2, - "boards": [ - "Map Set 5/16x17 Open Terrain 1.board", - "Map Set 5/16x17 Open Terrain 2.board" - ] - }, + "map": { "cols": 2, "boards": ["Map Set 5/16x17 Open Terrain 1.board", "Map Set 5/16x17 Open Terrain 2.board"] }, "date": "2026-08-25", "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": 3061, "to": 3064 }, + "years": { "from": 2872, "to": 3064 }, "board": { "columns": 8, "rows": 3 }, "sides": [ - { - "name": "Challenger", - "team": 1, - "start": "S", - "bv": 4086, - "tons": 155, - "cost": 20596750, - "units": [ - { "name": "Commando COM-7S", "row": 2, "facing": "N" }, - { "name": "Razorback RZK-9S", "row": 2, "facing": "N" }, - { "name": "Razorback RZK-9S", "row": 2, "facing": "N" }, - { "name": "Stiletto STO-4A", "row": 2, "facing": "N" }, - { "name": "Stiletto STO-4A", "row": 2, "facing": "N" } - ] - }, - { - "name": "OPFOR", - "team": 2, - "start": "N", - "bv": 4233, - "tons": 165, - "cost": 40861896, - "units": [ - { "name": "Sagittaire SGT-8R", "row": 0, "facing": "S" }, - { "name": "Thor (Summoner) A", "row": 0, "facing": "S" } - ] - } + { + "name": "Challenger", + "team": 1, + "start": "S", + "bv": 4086, + "tons": 155, + "cost": 20596750, + "units": [ + { "name": "Commando COM-7S", "row": 2, "facing": "N", "bv": 658 }, + { "name": "Razorback RZK-9S", "row": 2, "facing": "N", "bv": 855 }, + { "name": "Razorback RZK-9S", "row": 2, "facing": "N", "bv": 855 }, + { "name": "Stiletto STO-4A", "row": 2, "facing": "N", "bv": 859 }, + { "name": "Stiletto STO-4A", "row": 2, "facing": "N", "bv": 859 } + ] + }, + { + "name": "OPFOR", + "team": 2, + "start": "N", + "bv": 4233, + "tons": 165, + "cost": 40861896, + "units": [ + { "name": "Sagittaire SGT-8R", "row": 0, "facing": "S", "bv": 2088 }, + { "name": "Thor (Summoner) A", "row": 0, "facing": "S", "bv": 2145 } + ] + } ] } ] diff --git a/web/src/content/unit-stats.json b/web/src/content/unit-stats.json new file mode 100644 index 0000000..12343de --- /dev/null +++ b/web/src/content/unit-stats.json @@ -0,0 +1,254 @@ +{ + "units": { + "Alpha Wolf A": { + "tons": 90, + "cost": 40261000, + "year": 3150 + }, + "Archer ARC-2R": { + "tons": 70, + "cost": 6384973, + "year": 2474 + }, + "Archer ARC-4M": { + "tons": 70, + "cost": 7352273, + "year": 3049 + }, + "Argus AGS-4D": { + "tons": 60, + "cost": 11978400, + "year": 3062 + }, + "Atlas AS7-D": { + "tons": 100, + "cost": 9626000, + "year": 2755 + }, + "Black Hawk (Nova) A": { + "tons": 50, + "cost": 11426875, + "year": 2870 + }, + "Bushwacker BSW-L1": { + "tons": 55, + "cost": 8750525, + "year": 3063 + }, + "Cicada CDA-3C": { + "tons": 40, + "cost": 3306333, + "year": 2840 + }, + "Clint CLNT-5U": { + "tons": 40, + "cost": 6324080, + "year": 3062 + }, + "Commando COM-7S": { + "tons": 25, + "cost": 2965000, + "year": 3064 + }, + "Cougar H": { + "tons": 35, + "cost": 6403022, + "year": 3061 + }, + "Crusader CRD-3R": { + "tons": 65, + "cost": 5686010, + "year": 2752 + }, + "Drillson Heavy Hover Tank": { + "tons": 50, + "cost": 2498333, + "year": 3025 + }, + "Enfield END-6J": { + "tons": 50, + "cost": 8852000, + "year": 3060 + }, + "Firefly FFL-3PP": { + "tons": 30, + "cost": 3614000, + "year": 2801 + }, + "Firefly FFL-3PP3": { + "tons": 30, + "cost": 3763500, + "year": 2801 + }, + "Flea FLE-4": { + "tons": 20, + "cost": 1519200, + "year": 2501 + }, + "Griffin GRF-1N": { + "tons": 55, + "cost": 4957107, + "year": 2492 + }, + "Heavy LRM Carrier": { + "tons": 80, + "cost": 2940000, + "year": 3058 + }, + "Hellhound (Conjurer)": { + "tons": 50, + "cost": 5320500, + "year": 2829 + }, + "Hellspawn HSN-7D": { + "tons": 45, + "cost": 8375200, + "year": 3062 + }, + "Hussar HSR-500-D": { + "tons": 30, + "cost": 6152640, + "year": 3060 + }, + "Javelin JVN-11D": { + "tons": 30, + "cost": 4504240, + "year": 3062 + }, + "Locust LCT-1S": { + "tons": 20, + "cost": 1543600, + "year": 2567 + }, + "Locust LCT-1V": { + "tons": 20, + "cost": 1512400, + "year": 2499 + }, + "Marauder MAD-5S": { + "tons": 75, + "cost": 15498000, + "year": 3051 + }, + "Osiris OSR-3D": { + "tons": 30, + "cost": 5230550, + "year": 3063 + }, + "Razorback RZK-9S": { + "tons": 30, + "cost": 3535350, + "year": 3063 + }, + "Sagittaire SGT-8R": { + "tons": 95, + "cost": 20226375, + "year": 3063 + }, + "Savannah Master Hovercraft": { + "tons": 5, + "cost": 91667, + "year": 3025 + }, + "Shadow Hawk SHD-2H": { + "tons": 55, + "cost": 4539382, + "year": 2550 + }, + "Spider SDR-5D": { + "tons": 30, + "cost": 2942290, + "year": 2853 + }, + "Spider SDR-5K": { + "tons": 30, + "cost": 2728440, + "year": 2850 + }, + "Stiletto STO-4A": { + "tons": 35, + "cost": 5280525, + "year": 3061 + }, + "Stinger STG-3G": { + "tons": 20, + "cost": 1662240, + "year": 2581 + }, + "Stinger STG-3R": { + "tons": 20, + "cost": 1615440, + "year": 2479 + }, + "Templar TLR1-OB": { + "tons": 85, + "cost": 25484135, + "year": 3062 + }, + "Tessen TSN-1C": { + "tons": 50, + "cost": 10824000, + "year": 3061 + }, + "Thanatos TNS-4S": { + "tons": 75, + "cost": 19339250, + "year": 3061 + }, + "Thor (Summoner) A": { + "tons": 70, + "cost": 20635521, + "year": 2872 + }, + "Thor (Summoner) D": { + "tons": 70, + "cost": 20965958, + "year": 2872 + }, + "Thunderbolt TDR-5S": { + "tons": 65, + "cost": 5413760, + "year": 2505 + }, + "Turkina D": { + "tons": 95, + "cost": 28607719, + "year": 3061 + }, + "Uziel UZL-3S": { + "tons": 50, + "cost": 9783750, + "year": 3065 + }, + "Warhammer WHM-6R": { + "tons": 70, + "cost": 6070983, + "year": 2515 + }, + "Wasp WSP-1A": { + "tons": 20, + "cost": 1646640, + "year": 2471 + }, + "Wasp WSP-1W": { + "tons": 20, + "cost": 1633320, + "year": 3009 + }, + "Wolverine WVR-6R": { + "tons": 55, + "cost": 4827682, + "year": 2575 + }, + "Wraith TR1": { + "tons": 55, + "cost": 13225323, + "year": 3055 + }, + "Zeus ZEU-6T": { + "tons": 80, + "cost": 7752000, + "year": 3026 + } + } +} diff --git a/web/src/content/units.ts b/web/src/content/units.ts new file mode 100644 index 0000000..4ce41cc --- /dev/null +++ b/web/src/content/units.ts @@ -0,0 +1,94 @@ +/** + * What a machine weighs and costs, for any screen that lists units. + * + * The table is generated — `scripts/unit-stats.py` collects it from what real + * MegaMek reported about the daily scenarios — and it holds only what is true + * of a design wherever it turns up: weight, price, introduction year. Battle + * value is not here and cannot be, because BV 2.0 multiplies by the crew's + * gunnery and piloting, so the same machine is worth one figure with one pilot + * in it and another with a different one. Per-unit BV rides on the scenario + * instead: a daily carries it per unit in daily.json, and the hand-written + * catalog carries none at all yet. + * + * Which means every caller has to cope with not knowing. `statsFor` returns + * null rather than zeroes, and a force card shows a dash where a figure would + * go. Nine of the thirty-four machines the catalog fields have never appeared + * in a daily and so have no entry; that is a gap in the data, and drawing it as + * a gap is the honest rendering of it. + */ + +import unitsJson from "./unit-stats.json"; + +/** What one design weighs, costs and dates from. */ +export type UnitStats = { + /** Tons. Halves exist — a Savannah Master is 5, a light hover can be 3.5. */ + tons: number; + /** C-bills, as MegaMek prices it. */ + cost: number; + /** The year the design was introduced. */ + year: number; +}; + +const UNITS: Record = unitsJson.units; + +/** + * The name the table is keyed by. + * + * A scenario file writes `Drillson Heavy Hover Tank (Standard)`; MegaMek's own + * `getShortName()` drops the `(Standard)` model, so that is the spelling the + * report came back with and the spelling the table holds. Four units in the + * dailies differ this way. Only the trailing marker is stripped — `Hellhound + * (Conjurer) (Standard)` is reported as `Hellhound (Conjurer)`, and the part + * that is a real second name for the design has to survive. + */ +function keyFor(designation: string): string { + return designation.replace(/ \(Standard\)$/, ""); +} + +/** What the table knows about a machine, or null when it has never seen it. */ +export function statsFor(designation: string): UnitStats | null { + return UNITS[keyFor(designation)] ?? null; +} + +/** + * A force's totals, and whether they are the whole story. + * + * `complete` is false when any unit in the force is missing from the table, so + * a caller can mark the sum as a floor rather than print a total that quietly + * leaves machines out. A partial total is worth showing — most of a force is + * still most of a force — but not worth showing as if it were exact. + */ +export function forceTotals(designations: readonly string[]): { + tons: number; + cost: number; + complete: boolean; +} { + let tons = 0; + let cost = 0; + let complete = true; + for (const designation of designations) { + const stats = statsFor(designation); + if (!stats) { + complete = false; + continue; + } + tons += stats.tons; + cost += stats.cost; + } + return { tons, cost, complete }; +} + +/** Tons, with the half shown only when there is one: `5`, `3.5`. */ +export function tonsText(tons: number): string { + return Number.isInteger(tons) ? String(tons) : tons.toFixed(1); +} + +/** + * A price at a glance: `8.2M`, `915K`. Full precision is noise on a card that + * is showing eight of them, and the figure is there to compare forces with. + */ +export function costText(cost: number): string { + if (cost >= 1_000_000) return `${(cost / 1_000_000).toFixed(1)}M`; + if (cost >= 1_000) return `${Math.round(cost / 1_000)}K`; + return String(cost); +} diff --git a/web/src/screens/daily.ts b/web/src/screens/daily.ts index 62888c5..3dd2f22 100644 --- a/web/src/screens/daily.ts +++ b/web/src/screens/daily.ts @@ -55,7 +55,23 @@ import { drawMinimap, haveBoards, minimapSize } from "./minimap"; * decide, because the card spreads a row evenly and only the order the units * arrive in survives that. */ -export type Placement = { name: string; row: number; facing: string }; +export type Placement = { + name: string; + row: number; + facing: string; + /** + * What this machine is worth with the crew this scenario puts in it. + * + * Per scenario and not per design, because BV 2.0 multiplies by gunnery and + * piloting: the same Stinger STG-3R is 359 in one daily and 323 in another. + * That is also why it is here rather than in content/units.ts, which holds + * only what is true of a design wherever it turns up. + * + * Absent when the report a build was made from had nothing to say, so every + * reader has to cope with not knowing. + */ + bv?: number; +}; /** One force: what it fields and what it is worth. */ export type Side = { @@ -1121,6 +1137,42 @@ export function dailyCard(): HTMLElement | null { * The same panel the archive stacks, so a challenge looks like itself wherever * it is read, and the front page's copy is simply the one whose day is today. */ +/** + * The same picture the front page's card shows, without the way in. + * + * The lobby wants everything the card says about a fight — the board with both + * forces on it, the ground, what each side is worth, what the fight changes — + * and none of what the card *does*, because on that screen the way in is + * Deploy. So the figure is separated from the card rather than copied: one + * rendering of a fight, two places it is shown. + * + * `challenge` is null for a scenario out of the hand-written catalog, which + * has a name and a description and nothing else — no board, no deployment + * zones, no battle values, no boards to draw the ground from. Then this is the + * name and the description, in the card's own type, and the parts with no data + * behind them are left out rather than drawn empty. + */ +export function scenarioBrief( + name: string, + blurb: string, + challenge: Challenge | null, +): HTMLElement { + const text = el("div", { className: "daily-text-main" }, [ + el("h3", { className: "daily-brief-name", textContent: name }), + el("p", { className: "hint daily-blurb", textContent: blurb }), + ...(challenge ? [bvHeadToHead(challenge), ...overrideList(challenge)] : []), + ]); + return el("div", { className: "card daily daily-brief" }, [ + ...(challenge + ? [el("div", { className: "daily-board" }, [boardFigure(challenge)])] + : []), + el("div", { className: "daily-text" }, [ + text, + ...(challenge ? terrainTile(challenge) : []), + ]), + ]); +} + export function challengeCard(challenge: Challenge, day: string): HTMLElement { const play = playControl(challenge, day); // The one thing here that is not in the bundle: whether this reader has