From 6eb28edcd42e25d62830edad58df6a34af85201b Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 19 Aug 2026 12:05:21 -0400 Subject: [PATCH] feat: control and floor bots passthrough_bot passes on everything, so a passthrough seat is Princess with an extra process in the loop - the control's control. random_bot walks somewhere legal without looking at the enemy, and is the floor every later bot has to clear. --- bots/hexes.py | 56 ++++++++++++++++ bots/passthrough_bot.py | 33 ++++++++++ bots/random_bot.py | 141 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 bots/hexes.py create mode 100755 bots/passthrough_bot.py create mode 100755 bots/random_bot.py diff --git a/bots/hexes.py b/bots/hexes.py new file mode 100644 index 0000000..9df0808 --- /dev/null +++ b/bots/hexes.py @@ -0,0 +1,56 @@ +"""MegaMek's hex geometry, ported exactly. + +Offset coordinates, odd columns shifted down, directions 0-5 clockwise from +north. The two functions below are line-for-line ``Coords.xInDir`` and +``Coords.yInDir`` from ``megamek.common.board.Coords``; a bot that computes +neighbours any other way will disagree with the server about where a step lands, +and the disagreement shows up as illegal paths rather than as an error. + +This is the first piece of MegaMek's rules to be reimplemented outside the JVM. +It will not be the last, and each one arrives with the same obligation: match +the original exactly, and say where it came from. +""" + + +def x_in_dir(x: int, y: int, direction: int, distance: int = 1) -> int: + if direction in (1, 2): + return x + distance + if direction in (4, 5): + return x - distance + return x + + +def y_in_dir(x: int, y: int, direction: int, distance: int = 1) -> int: + if direction == 0: + return y - distance + if direction in (1, 5): + return y - ((distance + 1 - (x & 1)) // 2) + if direction in (2, 4): + return y + ((distance + (x & 1)) // 2) + return y + distance + + +def translated(x: int, y: int, direction: int, distance: int = 1) -> tuple[int, int]: + """The hex `distance` away in `direction`.""" + return x_in_dir(x, y, direction, distance), y_in_dir(x, y, direction, distance) + + +def distance(ax: int, ay: int, bx: int, by: int) -> int: + """Hex distance, via cube coordinates. + + MegaMek computes this with its own loop; cube distance is the same number + for these coordinates and is easier to check by eye. + """ + return _cube_distance(_to_cube(ax, ay), _to_cube(bx, by)) + + +def _to_cube(x: int, y: int) -> tuple[int, int, int]: + # Odd-q offset to cube. Columns are x, and odd columns sit half a hex lower, + # which is the (x & 1) term everywhere in this file. + q = x + r = y - ((x - (x & 1)) // 2) + return q, r, -q - r + + +def _cube_distance(a: tuple[int, int, int], b: tuple[int, int, int]) -> int: + return max(abs(a[0] - b[0]), abs(a[1] - b[1]), abs(a[2] - b[2])) diff --git a/bots/passthrough_bot.py b/bots/passthrough_bot.py new file mode 100755 index 0000000..3a696bf --- /dev/null +++ b/bots/passthrough_bot.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""A bot that never decides anything. Passes on every request. + +This is not a joke entry: it is the harness's control. A passthrough seat is +played entirely by Princess, so passthrough-vs-Princess is Princess against +itself with one extra process in the loop. If that match-up does not come out +near 50/50 over enough games, the difference is the harness - seat order, +deployment edge, the RNG, the bridge's latency - and not any bot. + +Run the control before believing any result, and again whenever the harness +changes. A measuring stick nobody has checked against a known length is a +source of confident wrong numbers. +""" + +import json +import sys + + +def main() -> None: + for line in sys.stdin: + line = line.strip() + if not line: + continue + message = json.loads(line) + if message.get("type") != "observation": + continue + reply = {"type": "action", "seq": message["seq"], "kind": "pass"} + sys.stdout.write(json.dumps(reply) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/bots/random_bot.py b/bots/random_bot.py new file mode 100755 index 0000000..1d81082 --- /dev/null +++ b/bots/random_bot.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""The floor: walk somewhere legal, more or less at random. + +Deliberately stupid, and useful for exactly that. Every later bot is measured +against two ends of a range - Princess at the top and this at the bottom - and a +change that cannot beat this one is not an improvement whatever its win rate +against Princess happens to be that evening. + +It does the least work that keeps its paths legal: it knows the board, it knows +which way it is facing, and it walks forward hex by hex until the next one would +be off the map, under water, or occupied. It has no idea where the enemy is. +That is the point. + +Seeded from SDS_SEED so a match can be repeated. +""" + +import json +import os +import random +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from hexes import translated # noqa: E402 + +# Terrain a walking Mek should not stroll into. Not a rules implementation - +# MegaMek decides legality and the host checks it - just enough to keep the +# illegal-path count low so the metric measures decisions rather than syntax. +IMPASSABLE_DEPTH = 1 + + +class Bot: + def __init__(self, rng: random.Random) -> None: + self.rng = rng + self.width = 0 + self.height = 0 + self.hexes: dict[tuple[int, int], dict] = {} + + def on_board(self, message: dict) -> None: + self.width = message["width"] + self.height = message["height"] + self.hexes = {(h["x"], h["y"]): h for h in message["hexes"]} + + def passable(self, x: int, y: int, occupied: set[tuple[int, int]]) -> bool: + if not (0 <= x < self.width and 0 <= y < self.height): + return False + if (x, y) in occupied: + return False + terrain = self.hexes.get((x, y), {}).get("terrain", {}) + return terrain.get("water", 0) <= IMPASSABLE_DEPTH + + def cost(self, fx: int, fy: int, tx: int, ty: int) -> int: + """What entering a hex costs, near enough. + + Counting one point per hex is what a first version does, and it is + wrong often enough to matter: woods, rough and a climb all cost more, + so the bot walks past its allowance and the server rejects the path. + The first run of this harness spent 7 of 74 decisions that way. + + Deliberately an approximation, not TW movement. The host checks + legality and Princess covers the rejects, so an occasional over-run is + a counted event rather than a broken match - and the number in the + `illegal` column is the honest measure of how good this guess is. + """ + source = self.hexes.get((fx, fy), {}) + target = self.hexes.get((tx, ty), {}) + terrain = target.get("terrain", {}) + points = 1 + points += terrain.get("woods", 0) + points += terrain.get("jungle", 0) + points += 1 if terrain.get("rough", 0) else 0 + points += 1 if terrain.get("rubble", 0) else 0 + points += terrain.get("water", 0) + points += abs(target.get("level", 0) - source.get("level", 0)) + return points + + def on_observation(self, message: dict) -> dict: + actor_id = message.get("actor") + units = {u["id"]: u for u in message["units"]} + actor = units.get(actor_id) + if actor is None: + return {"kind": "pass"} + + occupied = {(u["x"], u["y"]) for u in message["units"] if u["id"] != actor_id} + + steps: list[str] = [] + facing = actor["facing"] + # One turn at most, so the walk that follows goes somewhere rather than + # spending its whole allowance rotating on the spot. + turn = self.rng.choice([0, 0, 0, -1, 1]) + if turn == -1: + steps.append("TURN_LEFT") + facing = (facing - 1) % 6 + elif turn == 1: + steps.append("TURN_RIGHT") + facing = (facing + 1) % 6 + + # Walk, not run: running costs a to-hit penalty this bot has no way to + # value, and the point of the floor is that it is honest about what it + # does not know. + budget = max(0, actor.get("walkMp", 0) - len(steps)) + wanted = self.rng.randint(0, budget) + x, y = actor["x"], actor["y"] + spent = 0 + for _ in range(wanted): + nx, ny = translated(x, y, facing) + if not self.passable(nx, ny, occupied): + break + step_cost = self.cost(x, y, nx, ny) + if spent + step_cost > budget: + break + steps.append("FORWARDS") + spent += step_cost + x, y = nx, ny + + return {"kind": "move", "steps": steps} + + +def main() -> None: + rng = random.Random(int(os.environ.get("SDS_SEED", "0"))) + bot = Bot(rng) + for line in sys.stdin: + line = line.strip() + if not line: + continue + message = json.loads(line) + kind = message.get("type") + if kind == "board": + bot.on_board(message) + continue + if kind != "observation": + continue + reply = bot.on_observation(message) + reply["type"] = "action" + reply["seq"] = message["seq"] + sys.stdout.write(json.dumps(reply) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() -- 2.54.0