"""The suite's maps must let the two sides reach each other. `bridge/sds/Crossing.java` is what enforces this - it runs inside `sds validate` against MegaMek's own `Board`, which is the only thing that knows what a hex holds at match time. This file states the fact that check exists to protect, and does it without a JVM: the flood fill below reads the `.board` files directly, the way `bots/hexes.py` reimplements MegaMek's hex geometry outside the JVM. The fact: of the twelve boards the generator draws from, exactly one - `Map Set 4/16x17 River Delta 1` - has no water-free path between the north and south deployment edges. Its two 1v1 scenarios ran to the round limit at 100% BV in every game of the 40-game control, never making contact. Water fraction is not the test: `Large Lakes 1` is wetter and plays fine. """ import os import re import sys import unittest from pathlib import Path _REPO = Path(__file__).resolve().parent.parent sys.path.insert(0, str(_REPO / "bots")) from hexes import translated # noqa: E402 from sds.scenario import MAPS # noqa: E402 MM_HOME = Path(os.environ.get("MM_HOME", Path.home() / ".cache" / "mul-build" / "megamek")) BOARDS = MM_HOME / "data" / "boards" HEX = re.compile(r'^hex (\d+) (-?\d+) "([^"]*)"', re.M) SIZE = re.compile(r"^size (\d+) (\d+)", re.M) def read_board(path: Path) -> tuple[int, int, dict[tuple[int, int], dict[str, int]]]: """The terrain of every hex, keyed by zero-based (x, y). MegaMek writes the coordinate as two equal halves, one-based, so `0916` is column 9 row 16 and a board wider than 99 uses three digits a side. """ text = path.read_text(errors="replace") width, height = (int(n) for n in SIZE.search(text).groups()) hexes = {} for coord, _level, terrain in HEX.findall(text): half = len(coord) // 2 key = (int(coord[:half]) - 1, int(coord[half:]) - 1) parsed = {} for entry in terrain.split(";"): name, _, level = entry.partition(":") if level.lstrip("-").isdigit(): parsed[name] = int(level) hexes[key] = parsed return width, height, hexes def connects(open_hexes: set, start: set, goal: set) -> bool: """Flood fill over MegaMek's adjacency: is any goal hex reachable?""" seen = set(start) & open_hexes stack = list(seen) while stack: here = stack.pop() if here in goal: return True for direction in range(6): nxt = translated(here[0], here[1], direction) if nxt in open_hexes and nxt not in seen: seen.add(nxt) stack.append(nxt) return False class TestConnects(unittest.TestCase): """The search, on a grid that needs no MegaMek.""" WIDTH, HEIGHT = 8, 8 def grid(self, blocked: set) -> set: return { (x, y) for x in range(self.WIDTH) for y in range(self.HEIGHT) if (x, y) not in blocked } def edges(self) -> tuple[set, set]: top = {(x, 0) for x in range(self.WIDTH)} bottom = {(x, self.HEIGHT - 1) for x in range(self.WIDTH)} return top, bottom def test_open_grid_connects(self): top, bottom = self.edges() self.assertTrue(connects(self.grid(set()), top, bottom)) def test_full_band_blocks(self): top, bottom = self.edges() band = {(x, 4) for x in range(self.WIDTH)} self.assertFalse(connects(self.grid(band), top, bottom)) def test_one_gap_is_enough(self): """The gap is on an odd column, where a mis-ported parity term fails.""" top, bottom = self.edges() band = {(x, 4) for x in range(self.WIDTH) if x != 3} self.assertTrue(connects(self.grid(band), top, bottom)) def test_a_start_inside_the_wall_is_not_a_start(self): top, bottom = self.edges() band = {(x, 4) for x in range(self.WIDTH)} blocked = band | top self.assertFalse(connects(self.grid(blocked), top, bottom)) class TestSuiteMaps(unittest.TestCase): # A third of the board deep at each edge, which is more generous than any # deployment zone MegaMek hands out for an edge start. A path that has to # squeeze past a wider start zone is not one the check would miss. ZONE = 3 def setUp(self): if not BOARDS.is_dir(): self.skipTest(f"no MegaMek boards under {BOARDS}") def crossable(self, name: str) -> bool: width, height, hexes = read_board(BOARDS / f"{name}.board") # Any water at all is closed. Depth-1 water is legal, passable terrain # and the map is not broken - but no bot in this harness routes through # it, so a map that requires a crossing measures water aversion rather # than tactics. See Crossing.java for the whole argument. dry = {c for c, terrain in hexes.items() if terrain.get("water", 0) == 0} top = {(x, y) for x in range(width) for y in range(self.ZONE)} bottom = {(x, y) for x in range(width) for y in range(height - self.ZONE, height)} return connects(dry, top, bottom) def test_every_map_in_the_pool_is_crossable(self): blocked = sorted(name for name in MAPS if not self.crossable(name)) self.assertEqual(blocked, []) def test_river_delta_is_why_the_pool_excludes_it(self): # Named rather than drawn from MAPS: it was removed from the pool, and # the reason it was removed is the thing worth keeping a test on. self.assertFalse(self.crossable("Map Set 4/16x17 River Delta 1")) self.assertNotIn("Map Set 4/16x17 River Delta 1", MAPS) if __name__ == "__main__": unittest.main()