From 61647bce4ba4737c07bea5abdab5ea2d73b44015 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Mon, 17 Aug 2026 10:55:49 -0400 Subject: [PATCH] feat(daily-challenge): a tile of the real ground, inset on the card The board on the card is a diagram: who is in the fight, in their own colours, on a schematic eight by three. It says nothing at all about the ground, which is half of what decides a fight. This puts the real terrain in the corner of it - where the water is, where the cover is, where the roads run. An inset rather than a replacement. The diagram is what a reader looks at first, and a true-shape 16x17 board is portrait, so it could never sit behind those sprites anyway. Top right because forces stack towards their own edges and the era mark takes the middle, which leaves the corners. The shapes come from MegaMek's own `.board` files, which are in the arena image and not on the website, so `scripts/boards.py` reads them at authoring time and commits what the tile draws: one character a hex for terrain class, one for level, an exit mask per road hex. Eleven boards come to nine kilobytes. No terrain table, no exit inference and no MegaMek data reach the browser. `mms index` now carries each scenario's `map:` so the card knows which boards to compose, and the V2 reader picks it up alongside the options and bot knobs. --- crates/mms/src/bin/mms.rs | 28 ++++- crates/mms/src/v2.rs | 50 ++++++++- scripts/boards.py | 132 ++++++++++++++++++++++ web/src/content/boards.json | 212 ++++++++++++++++++++++++++++++++++++ web/src/content/daily.json | 11 ++ web/src/screens/daily.ts | 27 +++++ web/src/screens/minimap.ts | 141 ++++++++++++++++++++++++ web/src/styles.css | 30 +++++ 8 files changed, 627 insertions(+), 4 deletions(-) create mode 100755 scripts/boards.py create mode 100644 web/src/content/boards.json create mode 100644 web/src/screens/minimap.ts 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; +} -- 2.51.2