#!/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 orders legal. It walks forward hex by hex until the next one would be off the map, under water, or occupied, with no idea where the enemy is. It shoots everything it has at whichever enemy it is most likely to hit, and never thinks about heat. It deploys wherever the first legal hex is, pointed roughly at the middle of the map. None of that is good play, and none of it is borrowed. Every order it gives is one of these thirty lines, which is the property that makes a match log worth reading. Seeded from SDS_SEED so a match can be repeated. It never chooses which of its units acts: it answers about the host's `actor` and lets the host's own first-eligible pick stand. That is the floor for turn order as well, and it is what keeps this bot a check that a host offering an eligible set still works with a bot that ignores one. """ import json import os import random import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from hexes import distance, 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_deployment(self, message: dict) -> dict: hexes = message.get("deployHexes", []) if not hexes: return {"kind": "pass"} spot = self.rng.choice(hexes) return { "kind": "deploy", "x": spot["x"], "y": spot["y"], "facing": self.facing_toward_centre(spot["x"], spot["y"]), } def facing_toward_centre(self, x: int, y: int) -> int: """The facing whose next hex is closest to the middle of the board. Six candidates, one distance each. Cheaper to write than an angle, and it cannot disagree with the hex geometry the moves use. """ cx, cy = self.width // 2, self.height // 2 best, best_distance = 0, None for facing in range(6): nx, ny = translated(x, y, facing) d = distance(nx, ny, cx, cy) if best_distance is None or d < best_distance: best, best_distance = facing, d return best def on_firing(self, message: dict) -> dict: """Everything at one target: the one we are most likely to hit. No heat management at all, so this bot will shut itself down on a hot machine. Left in deliberately - the floor should lose for reasons that are easy to name. """ shots = message.get("shots", []) if not shots: return {"kind": "fire", "attacks": []} best = {} for shot in shots: target = shot["target"] current = best.get(target) if current is None or shot["toHit"] < current: best[target] = shot["toHit"] target = min(best, key=lambda t: best[t]) attacks = [ {"weapon": s["weapon"], "target": target} for s in shots if s["target"] == target ] return {"kind": "fire", "attacks": attacks} 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 phase = message.get("phase", "") if phase == "DEPLOYMENT": reply = bot.on_deployment(message) elif phase == "FIRING": reply = bot.on_firing(message) else: 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()