"""What one step costs each mover, read off the probe. `sds step-cost` walks every step of the four pathfind boards with each specimen rated past anything they can charge - so nothing is refused for cost and what comes back is the price - and tabulates `MovePath`'s own figure against the destination's terrain column, its depth, and the level the mover's feet change by. These tests read that table rather than a rulebook. It answered two questions the epic had been carrying as open: - **A Mek pays one point a level and everything else pays two**, climbing or dropping alike. Not the hovercraft rule it was taken for: tracked, hover and both kinds of infantry all pay two, and the Mek is the exception. That was 36 of the 44 routes `routes-check` refused. - **Infantry pay the terrain instead of the step.** Light woods costs a platoon 1 where it costs a Mek 2. """ import re import unittest from pathlib import Path TABLE = Path(__file__).resolve().parent.parent / "scenarios" / "move" / "stepcost.txt" def rows() -> list[dict]: out = [] for line in TABLE.read_text().splitlines(): if not line.startswith("cost "): continue row = dict(re.findall(r"(\w+)=(\S+)", line)) for key in ("terrain", "depth", "dlevel", "predicted", "steps"): row[key] = int(row[key]) row["megamek"] = [int(n) for n in re.findall(r"\d+", row["megamek"])] row["verdict"] = line.split()[-1] out.append(row) return out def totals() -> tuple[int, int, int]: for line in TABLE.read_text().splitlines(): found = re.match(r"stepcost total: (\d+) rows, (\d+) ambiguous, (\d+) mismatched", line) if found: return tuple(int(found.group(n)) for n in (1, 2, 3)) raise AssertionError(f"{TABLE} has no total line; run `sds step-cost`") class TestStepCost(unittest.TestCase): def setUp(self) -> None: self.rows = rows() self.assertTrue(self.rows, f"{TABLE} has no rows; run `sds step-cost`") def cost(self, mode: str, terrain: int, depth: int, dlevel: int) -> int: for row in self.rows: if ( row["mode"] == mode and row["terrain"] == terrain and row["depth"] == depth and row["dlevel"] == dlevel ): self.assertEqual(len(row["megamek"]), 1, row) return row["megamek"][0] raise AssertionError(f"no {mode} row for terrain={terrain} depth={depth} dlevel={dlevel}") def test_terrain_depth_and_level_determine_the_price(self) -> None: """No row carries two prices. This is the claim that makes the rest of the table mean anything: if these three numbers did not settle the cost, a model built on them could only ever be an average of something else. """ _, ambiguous, _ = totals() self.assertEqual(ambiguous, 0, "a key that does not determine the price") def test_a_mek_pays_one_a_level_and_everything_else_two(self) -> None: """The rule that was 36 of the 44 refusals, and it is not hover-only.""" for mode in ("TRACKED", "HOVER", "INF_LEG", "INF_JUMP"): flat = self.cost(mode, 0, 0, 0) self.assertEqual(self.cost(mode, 0, 0, +1) - flat, 2, f"{mode} climbing one level") self.assertEqual(self.cost(mode, 0, 0, -1) - flat, 2, f"{mode} dropping one level") flat = self.cost("BIPED", 0, 0, 0) self.assertEqual(self.cost("BIPED", 0, 0, +1) - flat, 1, "a Mek climbing") self.assertEqual(self.cost("BIPED", 0, 0, -1) - flat, 1, "a Mek dropping") def test_infantry_pay_the_terrain_and_not_the_step(self) -> None: """Light woods costs a platoon what it costs, not a point more.""" for mode in ("INF_LEG", "INF_JUMP"): self.assertEqual(self.cost(mode, 0, 0, 0), 1, f"{mode} on clear ground") self.assertEqual(self.cost(mode, 1, 0, 0), 1, f"{mode} in light woods") self.assertEqual(self.cost(mode, 2, 0, 0), 2, f"{mode} in heavy woods") # The Mek, for contrast: a point for the step and the terrain on top. self.assertEqual(self.cost("BIPED", 1, 0, 0), 2) self.assertEqual(self.cost("BIPED", 2, 0, 0), 3) def test_a_hovercraft_pays_nothing_for_depth(self) -> None: """It is on the surface of a pond, not on the bottom of it.""" self.assertEqual(self.cost("HOVER", 0, 3, 0), 1, "three deep, and still a point") self.assertEqual(self.cost("BIPED", 0, 3, 0), 4, "a Mek wades it") def test_what_the_model_still_misses_is_written_down(self) -> None: """One corner, and it is named rather than rounded off. A Mek entering water exactly two deep is charged one more than the model predicts - 18 steps of the 4,500-odd it takes on these boards. Depth 1 and depth 3 both fit, which is what makes it a corner rather than a term. It is *under*-predicted, which is the direction that can send the server a move it refuses, so it is pinned here to stay small rather than left to grow. """ rows_, _, mismatched = totals() self.assertEqual(mismatched, 2, "the set of rows the model misses has moved") missed = [row for row in self.rows if row["verdict"] == "MISMATCH"] self.assertTrue(all(row["mode"] == "BIPED" for row in missed), missed) self.assertTrue(all(row["depth"] == 2 for row in missed), missed) self.assertLess(sum(row["steps"] for row in missed), 50, missed) if __name__ == "__main__": unittest.main()