diff --git a/crates/mms/src/bin/mms.rs b/crates/mms/src/bin/mms.rs index 706bb10..bcbb337 100644 --- a/crates/mms/src/bin/mms.rs +++ b/crates/mms/src/bin/mms.rs @@ -223,6 +223,8 @@ fn index(args: &[String]) -> Result<(), String> { // Either format. V1 declares its board; V2 names board files and lets // their contents decide, so its size comes from arena's report - the // only place that has actually loaded the thing. + let mut boards: Vec = Vec::new(); + let mut cols = 1; let (name, blurb, rows, forces, overrides) = match detect_version(&text) { Some(Version::V1) => { let s = ScenarioV1::parse(&text).map_err(|e| format!("{}: {e}", path.display()))?; @@ -244,6 +246,8 @@ fn index(args: &[String]) -> Result<(), String> { path.display() ) })?; + boards = s.boards.clone(); + cols = s.cols; ( s.header.name.clone().unwrap_or_default(), s.header.description.clone().unwrap_or_default(), @@ -257,7 +261,8 @@ fn index(args: &[String]) -> Result<(), String> { } }; entries.push(entry( - &slot.slug, &slot.date, &name, &blurb, rows, &forces, &report, &overrides, + &slot.slug, &slot.date, &name, &blurb, rows, &forces, &report, &overrides, &boards, + cols, )?); } @@ -290,6 +295,8 @@ fn entry( forces: &[Force], report: &Report, overrides: &headquarters_mms::HeaderV2, + boards: &[String], + cols: i32, ) -> Result { let name = name.to_string(); let blurb = blurb.to_string(); @@ -404,11 +411,28 @@ fn entry( ); } + // The board files, so the card can draw a tile of the real terrain. Named + // rather than embedded: the shapes themselves live in content/boards.json, + // which several challenges share. + let map = if boards.is_empty() { + String::new() + } else { + format!( + "\n \"map\": {{ \"cols\": {cols}, \"boards\": [{}] }},", + boards + .iter() + .map(|b| json_string(b)) + .collect::>() + .join(", ") + ) + }; + 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 }}", + " {{\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), over, + map, json_string(date), json_string(&name), json_string(&blurb), diff --git a/crates/mms/src/v2.rs b/crates/mms/src/v2.rs index 8ca6f65..f7a72e2 100644 --- a/crates/mms/src/v2.rs +++ b/crates/mms/src/v2.rs @@ -323,6 +323,14 @@ factions: pub struct ScenarioV2 { pub header: HeaderV2, pub factions: Vec, + /// The board files the map is made of, in the order they are laid out. + /// + /// A single `map: file.board` is one entry; a `map: { cols, boards }` grid + /// is all of them. The card needs these to draw the terrain the fight is + /// actually on, which a V1 `Maps=` line said too - this is the same fact. + pub boards: Vec, + /// How many board files across. One for a scalar map. + pub cols: i32, } /// One faction of a V2 scenario. @@ -359,12 +367,45 @@ impl ScenarioV2 { let indent = |l: &str| l.len() - l.trim_start().len(); // Find the factions block and take its body. + // The map, before the factions: either a scalar or a grid of files. + let mut boards = Vec::new(); + let mut cols = 1; + if let Some(at) = lines + .iter() + .position(|l| l.trim_start().starts_with("map:") && indent(l) == 0) + { + let rest = lines[at] + .split_once(':') + .map(|(_, v)| v.trim()) + .unwrap_or(""); + if !rest.is_empty() { + boards.push(unquote(rest).to_string()); + } else { + let (body, _) = block(&lines, at); + for line in &body { + let s = line.trim(); + if let Some(v) = s.strip_prefix("cols:") { + cols = v.trim().parse().unwrap_or(1); + } else if let Some(v) = s.strip_prefix("- file:") { + boards.push(unquote(v.trim()).to_string()); + } else if let Some(v) = s.strip_prefix("file:") { + boards.push(unquote(v.trim()).to_string()); + } + } + } + } + let mut factions = Vec::new(); let Some(start) = lines .iter() .position(|l| l.trim_start().starts_with("factions:") && indent(l) == 0) else { - return ScenarioV2 { header, factions }; + return ScenarioV2 { + header, + factions, + boards, + cols, + }; }; let (body, _) = block(&lines, start); let refs: Vec<&str> = body.iter().map(String::as_str).collect(); @@ -433,7 +474,12 @@ impl ScenarioV2 { if let Some(f) = current.take() { factions.push(f); } - ScenarioV2 { header, factions } + ScenarioV2 { + header, + factions, + boards, + cols, + } } } diff --git a/scripts/boards.py b/scripts/boards.py new file mode 100755 index 0000000..42193e0 --- /dev/null +++ b/scripts/boards.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Read the boards the daily scenarios name, and write the card's tile data. + +The card draws a small minimap of the real terrain beside the schematic board. +That terrain lives in MegaMek's `.board` files, which are in the arena image +and not on the website, so it is read here at authoring time and committed — +the same arrangement as daily.json. + + scripts/boards.py --megamek --out web/src/content/boards.json + +What is encoded is only what the tile draws: one character per hex for terrain +class, one for level, and an exit mask per road hex. Everything else in a +`.board` file — themes, elevations of individual terrain layers, the fluff — is +dropped, because the tile is a summary and so is this. + +Format, from megamek/common/board/Board.java's own reader: + + size W H + hex "" "" + +The coordinate on a hex line is ignored by MegaMek itself (Board.java says so +in a comment): hexes are consumed in file order, so hex n is at +(n % width, n / width). Terrain is `;`-separated `name:level[:exits]`. +""" + +import argparse +import json +import pathlib +import re +import sys + +# What the tile draws, in the order a reader should notice it. The character is +# what goes in the payload. +OPEN, LIGHT_WOODS, HEAVY_WOODS, WATER, ROAD, BUILDING = ".", "w", "W", "~", "=", "#" + +LEVELS = "abcdefghijklmnopqrs" # -9..9, so a level is always one character + + +def classify(terrain: str) -> tuple[str, int]: + """The one class a hex reads as, and its road exit mask.""" + kinds, exits = {}, 0 + for part in terrain.split(";"): + if not part: + continue + bits = part.split(":") + name = bits[0] + level = bits[1] if len(bits) > 1 else "1" + kinds[name] = level + if name in ("road", "bridge") and len(bits) > 2: + try: + exits = int(bits[2]) + except ValueError: + pass + # Most-to-least significant for a glance: a building on a road is a + # building, a road through woods is a road. + if "building" in kinds or "fuel_tank" in kinds: + return BUILDING, exits + if "road" in kinds or "bridge" in kinds or "pavement" in kinds: + return ROAD, exits + if "water" in kinds: + return WATER, exits + if "woods" in kinds: + return (HEAVY_WOODS if kinds["woods"] not in ("1",) else LIGHT_WOODS), exits + if "jungle" in kinds: + return HEAVY_WOODS, exits + return OPEN, exits + + +def read_board(path: pathlib.Path) -> dict: + width = height = 0 + terrain, levels, roads = [], [], {} + for line in path.read_text(errors="replace").splitlines(): + line = line.strip() + if line.startswith("size "): + _, w, h = line.split() + width, height = int(w), int(h) + elif line.startswith("hex "): + parts = re.findall(r'"[^"]*"|\S+', line) + level = int(parts[2]) + kind, exits = classify(parts[3].strip('"')) + index = len(terrain) + terrain.append(kind) + levels.append(LEVELS[max(-9, min(9, level)) + 9]) + if kind == ROAD and exits: + roads[index] = f"{exits:x}" + if not width or len(terrain) != width * height: + raise SystemExit(f"{path}: {len(terrain)} hexes for a {width}x{height} board") + return { + "w": width, + "h": height, + "t": "".join(terrain), + "l": "".join(levels), + # Only the road hexes that declare an exit mask; the rest join up by + # sitting next to each other, which the renderer can infer. + "r": {str(k): v for k, v in roads.items()}, + } + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--megamek", required=True) + ap.add_argument("--scenarios", required=True) + ap.add_argument("--out", required=True) + args = ap.parse_args() + + # Every board any scenario names. + wanted = set() + for mms in sorted(pathlib.Path(args.scenarios).glob("*.mms")): + for m in re.finditer(r"(?:^map:[ \t]+|^\s*- file:[ \t]*)(\S.*)$", mms.read_text(), re.M): + name = m.group(1).strip().strip('"') + # `map:` followed by a block opens a grid; only a + # scalar or a `- file:` names a board. + if name and not name.endswith(":") and name.endswith(".board"): + wanted.add(name) + + boards = {} + for name in sorted(wanted): + path = pathlib.Path(args.megamek) / "data" / "boards" / name + if not path.exists(): + print(f"boards: {name} is not in {args.megamek}/data/boards", file=sys.stderr) + return 1 + boards[name] = read_board(path) + + out = pathlib.Path(args.out) + out.write_text(json.dumps({"boards": boards}, indent=1, sort_keys=True) + "\n") + total = sum(len(b["t"]) for b in boards.values()) + print(f"wrote {out} ({len(boards)} boards, {total} hexes)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/src/content/boards.json b/web/src/content/boards.json new file mode 100644 index 0000000..65e1167 --- /dev/null +++ b/web/src/content/boards.json @@ -0,0 +1,212 @@ +{ + "boards": { + "AGoAC Maps/16x17 Grassland 2.board": { + "h": 17, + "l": "jjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjkjjjkkkjjjjjjjllkjjkklkkjjjjjjjlkjjklmlkjjjjjjjjjjjkjlkkjjjjjjjjjjjjjjjjkjjjjjjjjjjjjjjlkjjjjjjjljjjjjjjjjjjjjlmmjjjjjjjjjjjjjllljjjjjjjjjjjjjklkkkjjjjjjjjjjjjkkjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj", + "r": { + "105": "14", + "109": "12", + "110": "12", + "119": "12", + "120": "12", + "122": "24", + "123": "22", + "124": "12", + "133": "12", + "134": "12", + "147": "12", + "148": "12", + "162": "a", + "178": "9", + "193": "12", + "194": "11", + "208": "12", + "95": "12" + }, + "t": "....~..............w~...w..........~~..wW.........~~~.............~......w........~~.......w...=....~~...=...==.......~==.===....w...==...........w==.~w..........=...~~.~........=...~~~~~w.....==.....~~~.W...=.....w~~~~~w...........~.......................................", + "w": 16 + }, + "Map Set 2/16x17 BattleTech.board": { + "h": 17, + "l": "jjjjjjjjjjjjjjjjjjjjjjjjjjklkljjjjjjjjjjjjjjlmjjjjjjjjjjjjjlmjjjjjjjjjjjjjjjkkjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjkjjjjjjjjjjjjjjllljjjjjjjjjjjjjmmljjjjjjjjjjjjklmjjjjjjjjjjjjjkjkjjjjjjjjjjjjjjjjjjj", + "r": {}, + "t": ".................www....w.........Ww........w.............................wW.............w..w........~~.ww............~~~............w.~.............W~~~...ww......w.~...................wW..............w........Ww......ww.....w.w.......w...................................", + "w": 16 + }, + "Map Set 3/16x17 Desert Sinkhole 1.board": { + "h": 17, + "l": "jjjjjijjjjjjjjjjjjjiihiijjjiiijjjjjhhghhijjhhhijjjihggghijihhhijjjiihgghijihhhijjjjihhghhjijhijjjjjjiihjjijiiijjjjjjjjijjijjijjjjjjjjjjhhghijjjjjjjjjihhgggiijjjjjjjihhihhhiijjjjjjjiihiiiihhjjjjijjiiihggghjjjjjiiijjijhjhjjijjjihhhhjjjijhhhjjjjiihhjjiihiihijjjjjjjjjjjijijjj", + "r": {}, + "t": "................................................................................................................................................................................................................................................................................", + "w": 16 + }, + "Map Set 3/16x17 Rolling Hills 1.board": { + "h": 17, + "l": "jjjjjkjjjjjkjjjjjjjkklkkjkklkjjjjjkllllkjklmlkjjjjkklllkjjlllkjjjjkkkkkjjklllkkjjkjjkjjjjkkkkkkjkkkkjkjjjkklkkkjkkkllljjjkllllkjjjklllkjjkklllkjjjklllkjjkkkllkjjjkkkkjjjjkjkjjjjkjjkjjkjljkjjjjjkkkjkkkllllkkjjkkkkjjkkkklllkjjkkkkkjjjkjkkkkjjjjkjjjjjjjjjkkjjjjjjjjjjjjjjjjjj", + "r": {}, + "t": "ww.....w.............www......w.w....ww.......w....................................................www.....w....w..ww.....www...ww..w.....www..........ww.............www...............w..........www........w....www..........ww..www....www..w...www.....ww..................", + "w": 16 + }, + "Map Set 4/16x17 Heavy Forest 1.board": { + "h": 17, + "l": "jjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjkklkkjjjjjjjjjjkllmlljjjjjjjjjjklmmllkkjjjjjjjjkkklllkkjjjjjjjjjjkklkkjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjkjkkkjjjjjjjjjkkkkkkkjjjjjjjjjkkkkkkkjjjjjjjjjkkkkkkkjjjjjjjjjjkkkkkjjjjjjjjjjjjjkjjjjjjjjjjjjjjjjjjjjj", + "r": {}, + "t": ".www..ww.w.www..wwWWwWWWwWwWwWWW.WWWWWWwwWWWwWWW.WWWWwwwwWWwwWWWwWWWwww.WWWwwWWwwWWWw..wWWWWWW..wWwWwww....wWWw.wwwWWWwwwwwwWww.wwWwWW.wwWWWw.....WWWWwWWWWWw.....WWWWWWWwwwww...WwWWWWWWWwwwwwwwwwWWwWwWWW..ww.wWWWWwwwwww.ww..wwWwWw..wwwwwwwwwww.ww.wwwwww.w.....w.w.w.w.....", + "w": 16 + }, + "Map Set 4/16x17 Large Lakes 1.board": { + "h": 17, + "l": "jjjjjkjjjkjjjljjjkklllkjjjkkkllljklllkkjjjjjkllljklkkkjjjjjjjjljjkkkkjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjkjjjjjjjjjjjjjjjkkkjjjkkkjjjkjjjjkkklllllkkkjjjjjjjkjljljkjjjjj", + "r": {}, + "t": "...w.ww~....w.........w.~~..w.......w.w~~~~~.....w....~~~~~~~~.~w..w.~~~~~~~~~~~.w.~~~~~~~~~~~~.WW~~~~~~~~~~~...ww~~~~~~~~~~~~..wW~~~~~~~~~~~~~~Ww~~~~~~~~~~~~~.w~~~~~~~~~~~~~...~~~~~~~~~~~~~..ww~~~~~~~~~~~~...ww.~~~.~~~~~ww..w.w......~..w..............................w...", + "w": 16 + }, + "Map Set 5/16x17 Moonscape 1.board": { + "h": 17, + "l": "jjjjjjjjjjjjjjjjjjjjjkkkjjjjjjjjjjjkklllkkjjjjjjjkkllkkkllkkjjjjjklkkjjjkklkjjjjjklkjiiijklkjjjjjklkjiiijklkjjjjjklkjjijjklkjjjjjkllkkjjjklkjjjjjjkkllkkkjjjkjjjjjjjkkljjijjkjjjjkkkjjkjjjjjkjjjjkjkjjkkjjjkkjjjjjkjjjjjkkkjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj", + "r": {}, + "t": "................................................................................................................................................................................................................................................................................", + "w": 16 + }, + "Map Set 5/16x17 Open Terrain 1.board": { + "h": 17, + "l": "jjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj", + "r": {}, + "t": ".................................................w.w.............wWwW............Ww......................................w...............W..............wWwW............w.w...............w......................w...............Ww.............................................", + "w": 16 + }, + "Map Set 6/16x17 City (Downtown).board": { + "h": 17, + "l": "jjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj", + "r": { + "100": "24", + "101": "24", + "103": "15", + "105": "12", + "106": "12", + "111": "9", + "118": "2a", + "120": "2a", + "127": "9", + "134": "9", + "136": "9", + "143": "9", + "149": "12", + "15": "9", + "150": "15", + "151": "2a", + "152": "15", + "153": "24", + "159": "9", + "163": "12", + "164": "12", + "167": "9", + "170": "24", + "171": "24", + "175": "9", + "177": "12", + "178": "12", + "183": "9", + "188": "24", + "189": "24", + "19": "12", + "191": "9", + "192": "12", + "199": "9", + "20": "12", + "206": "24", + "207": "3f", + "208": "24", + "209": "24", + "215": "9", + "221": "12", + "222": "12", + "223": "9", + "226": "24", + "227": "24", + "23": "9", + "231": "9", + "235": "12", + "236": "12", + "239": "9", + "244": "24", + "245": "24", + "247": "9", + "249": "12", + "250": "12", + "255": "9", + "26": "24", + "262": "24", + "263": "3f", + "264": "12", + "27": "24", + "271": "9", + "31": "9", + "33": "12", + "34": "12", + "39": "9", + "44": "24", + "45": "24", + "47": "9", + "48": "12", + "5": "12", + "55": "9", + "6": "12", + "62": "24", + "63": "3f", + "64": "24", + "65": "24", + "7": "9", + "71": "9", + "77": "12", + "78": "12", + "79": "9", + "8": "24", + "82": "24", + "83": "24", + "87": "9", + "9": "24", + "91": "12", + "92": "12", + "95": "9" + }, + "t": "#.###=====##..#=#.#==##=.#==#.#=.==..##=####==#==###..#=#.####====##.##=##..#===##==###=###==##=###w==.=.==####=###.ww=~=######=###.#w=~=######=###ww=====#####=###==#.=.#==###=#==#.##=####==#==.####.=#..###====#####=##..#===##==###=##.==##=#.##==.=#==#.##=..#...===.#...#=", + "w": 16 + }, + "Map Set 7/16x17 Archipelago 1.board": { + "h": 17, + "l": "jjjkjkjjjjjjjjjjjlllkkjjjjjjjjjjjmmlkkkkjjjjjjjjmmmkkjjjjjjjjjjjmmlljjjjjjjjjjjjlllljjjjjjjjjjjjllljjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjljjjjjjjjjjjjjjlljjjjjjjjjjjjjjmlljkkkkjjjjjjjnnnmlllkkjjjjjjjnnnmmllkjjjjjjjjjjmjlkkkjjjjjjjjjjjjjjkjjjjjjjjj", + "r": { + "114": "9", + "130": "9", + "146": "9", + "162": "9", + "178": "9", + "263": "9", + "7": "9", + "98": "9" + }, + "t": "~~~w~..=.~~~~~~~~.....W=..~~~~~~~....==...~~~~~~.w.==~~~.~~~~~~~ww=.~~~~~~~~~~~~..=.~~~~~~~~~~~~..=~~~~~~~~~~~~~~~=~~~~~~~~~~~~~~~=~~~~~~~~~~~~~~~=~~~~~~~~.~~~~~~=~~~~~~....~~~~.=...~~~..w.~~~~.==w......Ww~~~.w.w==....w~~~~~......==..w~~~~~~~.~..w=..~~~~~~~~~~~~.=.~~~~~~~", + "w": 16 + }, + "Map Set 7/16x17 Coast 1.board": { + "h": 17, + "l": "jjjjjjjjjjjjjjjjjjjkjjjjjjjjjjjjjjjkkjjjjjjjjjjjjjjjkjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjkjljjjjjjjjjjjjklllljjjjjjjjjjjmmmllkjjjjjjjjjjmmlklkjjjjjjjjjjjjjjkkjjjjjjjjjjjjjjkjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj", + "r": { + "15": "9", + "24": "24", + "25": "24", + "271": "9", + "42": "24", + "43": "24", + "60": "24", + "61": "24", + "63": "9", + "7": "5", + "78": "24" + }, + "t": "~~~~~WW=.~~~~~~=~~~w..##==~~~~.=~~~.w...w~==~..=~~~~.~~~~~~~==w=~~~~~~~~~~~~~.==~~~~~~~.~.~...w=~~~~~~..w......=~~~~~~.........=~~~~~~w........=~~~~~~~~...wW..=~~~~~~~~~..w...=~~~~~~~.......w=~~~~~~~.......w=~~~~~~~....w...=~~~~~.........w=~........ww....=..w.......w....=", + "w": 16 + } + } +} diff --git a/web/src/content/daily.json b/web/src/content/daily.json index be15b04..e41d067 100644 --- a/web/src/content/daily.json +++ b/web/src/content/daily.json @@ -2,6 +2,7 @@ "challenges": [ { "slug": "parity", + "map": { "cols": 2, "boards": ["Map Set 2/16x17 BattleTech.board", "Map Set 2/16x17 BattleTech.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.", @@ -41,6 +42,7 @@ { "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 2.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.", @@ -104,6 +106,7 @@ }, { "slug": "two-against-the-star", + "map": { "cols": 2, "boards": ["Map Set 4/16x17 Heavy Forest 1.board", "Map Set 4/16x17 Heavy Forest 1.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.", @@ -140,6 +143,7 @@ }, { "slug": "downtown-arbitration", + "map": { "cols": 2, "boards": ["Map Set 6/16x17 City (Downtown).board", "Map Set 6/16x17 City (Downtown).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.", @@ -178,6 +182,7 @@ }, { "slug": "the-long-walk", + "map": { "cols": 2, "boards": ["Map Set 5/16x17 Moonscape 1.board", "Map Set 5/16x17 Moonscape 1.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.", @@ -214,6 +219,7 @@ }, { "slug": "tracks-and-treads", + "map": { "cols": 2, "boards": ["Map Set 7/16x17 Coast 1.board", "Map Set 7/16x17 Coast 1.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.", @@ -250,6 +256,7 @@ }, { "slug": "one-of-ours", + "map": { "cols": 2, "boards": ["Map Set 3/16x17 Rolling Hills 1.board", "Map Set 3/16x17 Rolling Hills 1.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.", @@ -284,6 +291,7 @@ }, { "slug": "indirect-fire", + "map": { "cols": 2, "boards": ["Map Set 4/16x17 Large Lakes 1.board", "Map Set 4/16x17 Large Lakes 1.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.", @@ -319,6 +327,7 @@ }, { "slug": "sinkhole", + "map": { "cols": 2, "boards": ["Map Set 3/16x17 Desert Sinkhole 1.board", "Map Set 3/16x17 Desert Sinkhole 1.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.", @@ -355,6 +364,7 @@ }, { "slug": "archipelago", + "map": { "cols": 2, "boards": ["Map Set 7/16x17 Archipelago 1.board", "Map Set 7/16x17 Archipelago 1.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.", @@ -391,6 +401,7 @@ }, { "slug": "scrapyard-arithmetic", + "map": { "cols": 2, "boards": ["Map Set 5/16x17 Open Terrain 1.board", "Map Set 5/16x17 Open Terrain 1.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.", diff --git a/web/src/screens/daily.ts b/web/src/screens/daily.ts index d0f2492..0a0a638 100644 --- a/web/src/screens/daily.ts +++ b/web/src/screens/daily.ts @@ -45,6 +45,7 @@ import { type RawOverrides, } from "../content/overrides"; import { ERA_ICONS } from "../content/trivia"; +import { drawMinimap, haveBoards } from "./minimap"; /** * A unit on the schematic board: which machine, which row it stands in, and @@ -104,6 +105,12 @@ export type Challenge = { * form, and absent for a challenge that changes nothing. */ overrides?: RawOverrides; + /** + * The board files the fight is on, and how many across. Written by + * `mms index` from the scenario's `map:`; the shapes themselves are in + * content/boards.json, which several challenges share. + */ + map?: { cols: number; boards: string[] }; date: string; name: string; blurb: string; @@ -788,10 +795,30 @@ export function boardFigure(challenge: Challenge): HTMLElement { } watermark.hidden = !era; + // The real terrain, as an inset. The board above it is a diagram of who is + // in the fight; this is what they are fighting over. Small on purpose - at + // this size only the shapes survive, which is the point. + const inset: HTMLElement[] = []; + const map = challenge.map; + if (map && haveBoards(map.boards)) { + const tile = el("canvas", { className: "daily-terrain" }); + tile.width = 320; + tile.height = Math.round(320 * (17 / (16 * Math.max(1, map.cols)))); + tile.role = "img"; + tile.ariaLabel = "The ground the fight is on"; + // After the card is on the page, like the board itself: nothing waits for + // a picture. + queueMicrotask(() => { + if (!drawMinimap(tile, map.boards, map.cols)) tile.hidden = true; + }); + inset.push(el("div", { className: "daily-terrain-inset" }, [tile])); + } + return el("div", { className: "daily-map-frame" }, [ canvas, watermark, ...tags, + ...inset, ]); } diff --git a/web/src/screens/minimap.ts b/web/src/screens/minimap.ts new file mode 100644 index 0000000..bcae4a7 --- /dev/null +++ b/web/src/screens/minimap.ts @@ -0,0 +1,141 @@ +/** + * The real terrain, as a small tile beside the schematic board. + * + * The card's board is a diagram: eight tiles by three, forces stood on it in + * their own colours. It says who is in the fight and says nothing at all about + * the ground, which is half of what decides one. This draws the ground. + * + * Not a replacement for that diagram and not a map to play from — an inset, at + * a size where only the shapes survive: where the water is, where the cover is, + * where the roads run, where the ground breaks. Everything else in a `.board` + * file is dropped by `scripts/boards.py` before it ever reaches the browser. + * + * One hue, value carrying a fixed importance order: + * + * low ground -> high ground -> woods -> water -> roads -> buildings + * darkest ------------------------------------------------> lightest + * + * so it survives being 200 pixels wide, and survives colour blindness. The two + * darkest values are the card's own board colours, so the tile sits in the card + * rather than on it. + */ + +import boardsJson from "../content/boards.json"; + +/** One board, as `scripts/boards.py` encodes it. */ +type Board = { + w: number; + h: number; + /** One character a hex: `.` open, `w`/`W` woods, `~` water, `=` road, `#` building. */ + t: string; + /** One character a hex: the level, `a`..`s` for -9..9. */ + l: string; + /** Exit mask per road hex that declares one, keyed by hex index. */ + r: Record; +}; + +const BOARDS: Record = boardsJson.boards; + +/** Level characters decode to a signed level. */ +const LEVELS = "abcdefghijklmnopqrs"; + +const INK = { + ground: "#0b1017", + grid: "#172134", + high: "#22314b", + woods: "#2f5f86", + water: "#3b82c4", + edge: "#63a6de", + road: "#8fb6d9", + building: "#cfe2f2", +}; + +/** + * Draw the boards a scenario names, laid out `cols` across, onto a canvas. + * + * Returns false when the tile cannot be drawn — a board the index named but + * `boards.json` has never heard of — so the caller can leave the inset out + * rather than draw an empty box. + */ +export function drawMinimap( + canvas: HTMLCanvasElement, + names: readonly string[], + cols: number, +): boolean { + const sheets = names.map((n) => BOARDS[n]).filter((b): b is Board => !!b); + if (sheets.length === 0 || sheets.length !== names.length) return false; + + const across = Math.max(1, cols); + const down = Math.ceil(sheets.length / across); + const sheetW = sheets[0]!.w; + const sheetH = sheets[0]!.h; + const hexes = { x: sheetW * across, y: sheetH * down }; + + const ctx = canvas.getContext("2d"); + if (!ctx) return false; + // A hex is drawn as a flat-topped cell on the same lattice the board uses: + // columns step three quarters of a width and every other one drops half a + // height. The tile is sized to fill the canvas it was given. + const radius = Math.min( + canvas.width / (hexes.x * 1.5 + 0.5), + canvas.height / (hexes.y * Math.sqrt(3) + Math.sqrt(3) / 2), + ); + const halfH = radius * (Math.sqrt(3) / 2); + + ctx.fillStyle = INK.ground; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const at = (sheet: number, x: number, y: number) => { + const ox = (sheet % across) * sheetW; + const oy = Math.floor(sheet / across) * sheetH; + return { + cx: (ox + x) * radius * 1.5 + radius, + cy: (oy + y) * halfH * 2 + halfH * ((ox + x) % 2 === 1 ? 2 : 1), + }; + }; + + const hex = (cx: number, cy: number, fill: string) => { + ctx.beginPath(); + for (let v = 0; v < 6; v++) { + const a = (Math.PI / 3) * v; + const px = cx + radius * Math.cos(a); + const py = cy + halfH * Math.sin(a) * (2 / Math.sqrt(3)); + v === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); + } + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + }; + + // Ground first, banded against the board's own median so a board that sits + // entirely at level 2 reads as flat rather than as a plateau. + const allLevels = sheets.flatMap((s) => + [...s.l].map((c) => LEVELS.indexOf(c) - 9), + ); + const median = + allLevels.slice().sort((a, b) => a - b)[Math.floor(allLevels.length / 2)] ?? + 0; + + for (const [i, sheet] of sheets.entries()) { + for (let y = 0; y < sheet.h; y++) { + for (let x = 0; x < sheet.w; x++) { + const n = y * sheet.w + x; + const kind = sheet.t[n]!; + const level = LEVELS.indexOf(sheet.l[n]!) - 9; + const { cx, cy } = at(i, x, y); + let fill = level > median ? INK.high : INK.grid; + if (kind === "w" || kind === "W") fill = INK.woods; + else if (kind === "~") fill = INK.water; + else if (kind === "=") fill = INK.road; + else if (kind === "#") fill = INK.building; + hex(cx, cy, fill); + } + } + } + return true; +} + +/** Whether a tile can be drawn for these boards at all. */ +export function haveBoards(names: readonly string[]): boolean { + return names.length > 0 && names.every((n) => !!BOARDS[n]); +} diff --git a/web/src/styles.css b/web/src/styles.css index 3b9e886..3204e85 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -4630,3 +4630,33 @@ footer .debug { font-weight: 700; color: color-mix(in srgb, var(--text) 55%, transparent); } + +/* --- the terrain inset ---------------------------------------------------- + The board above is a diagram of who is in the fight; this is the ground they + are fighting over. Tucked into a corner of the frame, small enough that only + the shapes of it read - where the water is, where the cover is. */ +.daily-terrain-inset { + position: absolute; + /* Top right. The forces stack towards their own edges and the era mark takes + the middle, so the corners are the only ground reliably free - and the top + right is the one a force reaches last, because a rank fills from its + centre outwards. */ + right: 0.4rem; + top: 0.4rem; + width: 23%; + max-width: 7.5rem; + border: 1px solid color-mix(in srgb, var(--brand) 30%, transparent); + border-radius: 0.2rem; + overflow: hidden; + /* Sits over the field, so it carries its own ground rather than letting the + hexes show through and read as terrain that is not there. */ + background: rgb(11 16 23); + box-shadow: 0 2px 8px rgb(0 0 0 / 45%); + pointer-events: none; +} + +.daily-terrain { + display: block; + width: 100%; + height: auto; +}