From 2df70c8ae0d14b1a3c458f71d605e0595a8ba999 Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 22 Apr 2026 16:48:12 -0400 Subject: [PATCH] =?UTF-8?q?feat(bot):=20add=20Nomad=20bot=20(Python)=20?= =?UTF-8?q?=E2=80=94=20constant=20relocation=20archetype?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile archetype that never camps. Picks a target region every ~20 turns (random corner, opposite side, or enemy core), migrates all units toward it, briefly engages enemies on arrival, then relocates again. Co-Authored-By: Claude Opus 4.7 --- bots/nomad/Dockerfile | 11 + bots/nomad/grid.py | 64 ++++++ bots/nomad/main.py | 479 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 554 insertions(+) create mode 100644 bots/nomad/Dockerfile create mode 100644 bots/nomad/grid.py create mode 100644 bots/nomad/main.py diff --git a/bots/nomad/Dockerfile b/bots/nomad/Dockerfile new file mode 100644 index 0000000..0612165 --- /dev/null +++ b/bots/nomad/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.13-slim + +WORKDIR /app +COPY main.py grid.py . + +ENV BOT_PORT=8080 +ENV BOT_SECRET="" + +EXPOSE 8080 + +CMD ["python3", "main.py"] diff --git a/bots/nomad/grid.py b/bots/nomad/grid.py new file mode 100644 index 0000000..9fe229e --- /dev/null +++ b/bots/nomad/grid.py @@ -0,0 +1,64 @@ +"""Grid utility functions for AI Code Battle. + +Provides toroidal distance calculations, neighbor enumeration, +and BFS pathfinding on a wrapping grid. +""" + +from collections import deque + + +def toroidal_manhattan(r1, c1, r2, c2, cols, rows): + """Manhattan distance with wrap-around on a toroidal grid.""" + dr = abs(r1 - r2) + dc = abs(c1 - c2) + dr = min(dr, rows - dr) + dc = min(dc, cols - dc) + return dr + dc + + +def toroidal_chebyshev(r1, c1, r2, c2, cols, rows): + """Chebyshev distance with wrap-around on a toroidal grid.""" + dr = abs(r1 - r2) + dc = abs(c1 - c2) + dr = min(dr, rows - dr) + dc = min(dc, cols - dc) + return max(dr, dc) + + +def neighbors(row, col, rows, cols): + """Return 8-directional neighbors with wrap-around.""" + offsets = [(-1, -1), (-1, 0), (-1, 1), + (0, -1), (0, 1), + (1, -1), (1, 0), (1, 1)] + return [((row + dr) % rows, (col + dc) % cols) for dr, dc in offsets] + + +def bfs(start, goal, passable, rows, cols): + """BFS pathfinding on a toroidal grid. + + Args: + start: (row, col) tuple + goal: (row, col) tuple + passable: callable(row, col) -> bool + rows, cols: grid dimensions + + Returns: + List of (row, col) from start to goal (exclusive of start), + or None if no path exists. + """ + if start == goal: + return [] + + queue = deque([(start, [])]) + visited = {start} + + while queue: + (r, c), path = queue.popleft() + for nr, nc in neighbors(r, c, rows, cols): + if (nr, nc) == goal: + return path + [(nr, nc)] + if (nr, nc) not in visited and passable(nr, nc): + visited.add((nr, nc)) + queue.append(((nr, nc), path + [(nr, nc)])) + + return None diff --git a/bots/nomad/main.py b/bots/nomad/main.py new file mode 100644 index 0000000..0ceb3dc --- /dev/null +++ b/bots/nomad/main.py @@ -0,0 +1,479 @@ +"""NomadBot — constant relocation archetype. + +Mobile archetype that never stays in one region. Picks a target region every +~20 turns, migrates all units toward it, briefly engages enemies on arrival, +then picks a new region. Spawns join the current migration. + +Archetype axes: Medium Aggression, Low Economy, High Exploration, Low Formation. +""" + +import hashlib +import hmac +import json +import math +import os +import random +from http.server import HTTPServer, BaseHTTPRequestHandler + +from grid import toroidal_manhattan, bfs + +# ── Match state ────────────────────────────────────────────────────────────── + +_matches: dict = {} # match_id -> MatchState + + +class MatchState: + """Persistent state for a single match.""" + + def __init__(self, match_id: str): + self.match_id = match_id + self.target: tuple[int, int] | None = None + self.target_turn: int = -999 # turn when current target was set + self.arrived: bool = False + self.arrive_turn: int = -999 # turn when group arrived at target + + def is_stale(self, turn: int) -> bool: + return turn - self.target_turn > 200 + + +def _get_state(match_id: str, turn: int) -> MatchState: + if match_id not in _matches: + _matches[match_id] = MatchState(match_id) + state = _matches[match_id] + if state.is_stale(turn): + del _matches[match_id] + _matches[match_id] = MatchState(match_id) + state = _matches[match_id] + return state + + +# ── Game state parsing ─────────────────────────────────────────────────────── + + +class GameState: + __slots__ = ( + "match_id", "turn", "rows", "cols", + "vision_radius2", "attack_radius2", "spawn_cost", "energy_interval", + "max_turns", + "you_id", "you_energy", "you_score", + "bots", "energy", "cores", "walls", "dead", + ) + + def __init__(self, raw: dict): + self.match_id = raw["match_id"] + self.turn = raw["turn"] + cfg = raw["config"] + self.rows = cfg["rows"] + self.cols = cfg["cols"] + self.vision_radius2 = cfg["vision_radius2"] + self.attack_radius2 = cfg["attack_radius2"] + self.spawn_cost = cfg["spawn_cost"] + self.energy_interval = cfg["energy_interval"] + self.max_turns = cfg["max_turns"] + + you = raw["you"] + self.you_id = you["id"] + self.you_energy = you["energy"] + self.you_score = you["score"] + + self.bots = raw["bots"] + self.energy = raw["energy"] + self.cores = raw["cores"] + self.walls = raw["walls"] + self.dead = raw["dead"] + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +DIRECTIONS = { + "N": (-1, 0), + "S": (1, 0), + "E": (0, 1), + "W": (0, -1), +} + + +def _wrap(r: int, c: int, rows: int, cols: int) -> tuple[int, int]: + return r % rows, c % cols + + +def _dist2(r1: int, c1: int, r2: int, c2: int, rows: int, cols: int) -> int: + dr = abs(r1 - r2) + dc = abs(c1 - c2) + dr = min(dr, rows - dr) + dc = min(dc, cols - dc) + return dr * dr + dc * dc + + +def _centroid(positions: list[tuple[int, int]], rows: int, cols: int) -> tuple[int, int]: + """Compute centroid of positions on a toroidal grid using mean of circular coords.""" + if not positions: + return rows // 2, cols // 2 + sum_sin_r = sum(math.sin(2 * math.pi * r / rows) for r, _ in positions) + sum_cos_r = sum(math.cos(2 * math.pi * r / rows) for r, _ in positions) + sum_sin_c = sum(math.sin(2 * math.pi * c / cols) for _, c in positions) + sum_cos_c = sum(math.cos(2 * math.pi * c / cols) for _, c in positions) + n = len(positions) + cr = (math.atan2(sum_sin_r / n, sum_cos_r / n) / (2 * math.pi) * rows) % rows + cc = (math.atan2(sum_sin_c / n, sum_cos_c / n) / (2 * math.pi) * cols) % cols + return int(round(cr)) % rows, int(round(cc)) % cols + + +def _cardinal_moves(r: int, c: int, rows: int, cols: int): + for d, (dr, dc) in DIRECTIONS.items(): + yield _wrap(r + dr, c + dc, rows, cols), d + + +def _pick_target( + centroid: tuple[int, int], + rows: int, cols: int, + my_cores: list[tuple[int, int]], + enemy_cores: list[tuple[int, int]], +) -> tuple[int, int]: + """Pick a migration target: random corner, opposite side, or enemy core.""" + candidates = [] + + # Map corners + corners = [ + (rows // 5, cols // 5), + (rows // 5, 4 * cols // 5), + (4 * rows // 5, cols // 5), + (4 * rows // 5, 4 * cols // 5), + ] + candidates.extend(corners) + + # Opposite-of-centroid point + opp = ((centroid[0] + rows // 2) % rows, (centroid[1] + cols // 2) % cols) + candidates.append(opp) + + # Edge midpoints + candidates.append((0, cols // 2)) + candidates.append((rows - 1, cols // 2)) + candidates.append((rows // 2, 0)) + candidates.append((rows // 2, cols - 1)) + + # Enemy cores (high priority targets) + candidates.extend(enemy_cores) + + # Filter out positions too close to current centroid + far_enough = [ + p for p in candidates + if _dist2(p[0], p[1], centroid[0], centroid[1], rows, cols) + > (min(rows, cols) // 4) ** 2 + ] + + if not far_enough: + far_enough = candidates + + # Weighted random: prefer enemy cores, then far candidates + weights: list[float] = [] + for p in far_enough: + if p in enemy_cores: + weights.append(4.0) + else: + d = math.sqrt(_dist2(p[0], p[1], centroid[0], centroid[1], rows, cols)) + weights.append(d / max(rows, cols)) + + return random.choices(far_enough, weights=weights, k=1)[0] + + +def _direction_toward( + r: int, c: int, tr: int, tc: int, rows: int, cols: int, + wall_set: set[tuple[int, int]], + claimed: set[tuple[int, int]], + enemy_positions: set[tuple[int, int]], +) -> tuple[int, int] | None: + """Pick the cardinal direction that minimizes toroidal distance to target.""" + best_pos = None + best_dir = None + best_dist = float("inf") + + for (nr, nc), d in _cardinal_moves(r, c, rows, cols): + if (nr, nc) in wall_set: + continue + if (nr, nc) in claimed: + continue + dist = toroidal_manhattan(nr, nc, tr, tc, rows, cols) + # Avoid stepping directly onto enemies unless aggressive + if (nr, nc) in enemy_positions: + dist += 4 # penalty, not a hard block + if dist < best_dist: + best_dist = dist + best_pos = (nr, nc) + best_dir = d + + return best_pos, best_dir + + +def _flee_direction( + r: int, c: int, + enemies: list[tuple[int, int]], + rows: int, cols: int, + wall_set: set[tuple[int, int]], + claimed: set[tuple[int, int]], +) -> tuple[int, int] | None: + """Pick the direction that maximizes minimum distance from enemies.""" + if not enemies: + return None + + best_pos = None + best_dir = None + best_min_dist = -1 + + for (nr, nc), d in _cardinal_moves(r, c, rows, cols): + if (nr, nc) in wall_set: + continue + if (nr, nc) in claimed: + continue + min_d = min(_dist2(nr, nc, er, ec, rows, cols) for er, ec in enemies) + if min_d > best_min_dist: + best_min_dist = min_d + best_pos = (nr, nc) + best_dir = d + + return best_pos, best_dir + + +# ── Core strategy ──────────────────────────────────────────────────────────── + +RELOCATE_INTERVAL = 20 # turns between relocations +ENGAGE_DURATION = 10 # turns to engage at destination before moving on +ARRIVE_RADIUS_FACTOR = 0.15 # fraction of map dimension = "arrived" +FLEE_RADIUS2_BONUS = 9 # extra buffer beyond attack radius + + +def compute_moves(state: GameState) -> list[dict]: + rows, cols = state.rows, state.cols + turn = state.turn + attack_r2 = state.attack_radius2 + + wall_set = {(w["row"], w["col"]) for w in state.walls} + + my_positions: list[tuple[int, int]] = [] + for b in state.bots: + if b["owner"] == state.you_id: + my_positions.append((b["position"]["row"], b["position"]["col"])) + + if not my_positions: + return [] + + enemy_positions: list[tuple[int, int]] = [] + for b in state.bots: + if b["owner"] != state.you_id: + enemy_positions.append((b["position"]["row"], b["position"]["col"])) + enemy_set = set(enemy_positions) + + my_cores: list[tuple[int, int]] = [] + enemy_cores: list[tuple[int, int]] = [] + for c in state.cores: + pos = (c["position"]["row"], c["position"]["col"]) + if c["owner"] == state.you_id and c["active"]: + my_cores.append(pos) + elif c["owner"] != state.you_id and c["active"]: + enemy_cores.append(pos) + + centroid = _centroid(my_positions, rows, cols) + ms = _get_state(state.match_id, turn) + + arrive_radius = int(min(rows, cols) * ARRIVE_RADIUS_FACTOR) + + # ── Decide if we need a new target ─────────────────────────────────── + need_new_target = False + + if ms.target is None: + need_new_target = True + elif ms.arrived: + # Already arrived — stay for ENGAGE_DURATION then relocate + if turn - ms.arrive_turn >= ENGAGE_DURATION: + need_new_target = True + # Also relocate if enemies are gone from our area + elif not any( + _dist2(centroid[0], centroid[1], er, ec, rows, cols) + < (arrive_radius * 3) ** 2 + for er, ec in enemy_positions + ): + if turn - ms.arrive_turn >= 5: # minimum stay time + need_new_target = True + else: + # Haven't arrived yet — check if we've been stuck too long + turns_traveling = turn - ms.target_turn + if turns_traveling >= RELOCATE_INTERVAL * 2: + need_new_target = True + + if need_new_target: + ms.target = _pick_target(centroid, rows, cols, my_cores, enemy_cores) + ms.target_turn = turn + ms.arrived = False + ms.arrive_turn = -999 + + # ── Check arrival ──────────────────────────────────────────────────── + if not ms.arrived and ms.target: + dist_to_target = toroidal_manhattan( + centroid[0], centroid[1], ms.target[0], ms.target[1], rows, cols + ) + if dist_to_target <= arrive_radius: + ms.arrived = True + ms.arrive_turn = turn + + target = ms.target + + # ── Compute moves for each bot ─────────────────────────────────────── + moves: list[dict] = [] + claimed: set[tuple[int, int]] = set() + + # Sort bots: process bots closest to enemies first (they may need to flee) + def _enemy_threat(pos): + if not enemy_positions: + return 999 + return min(_dist2(pos[0], pos[1], e[0], e[1], rows, cols) for e in enemy_positions) + + sorted_positions = sorted(my_positions, key=_enemy_threat) + + for r, c in sorted_positions: + flee_r2 = attack_r2 + FLEE_RADIUS2_BONUS + nearby_enemies = [ + (er, ec) for er, ec in enemy_positions + if _dist2(r, c, er, ec, rows, cols) <= flee_r2 + ] + + best_pos = None + best_dir = None + + # Priority 1: Flee if enemy is dangerously close + if nearby_enemies: + result = _flee_direction(r, c, nearby_enemies, rows, cols, wall_set, claimed) + if result: + best_pos, best_dir = result + + # Priority 2: If arrived and engaging, chase nearby enemies + if best_pos is None and ms.arrived and enemy_positions: + # Find nearest enemy + nearest_enemy = min( + enemy_positions, + key=lambda e: _dist2(r, c, e[0], e[1], rows, cols), + ) + er, ec = nearest_enemy + if _dist2(r, c, er, ec, rows, cols) <= (attack_r2 * 4): + result = _direction_toward( + r, c, er, ec, rows, cols, wall_set, claimed, enemy_set, + ) + if result: + best_pos, best_dir = result + + # Priority 3: Move toward migration target + if best_pos is None and target: + result = _direction_toward( + r, c, target[0], target[1], rows, cols, wall_set, claimed, enemy_set, + ) + if result: + best_pos, best_dir = result + + # Priority 4: Spread from friendly bots (avoid clustering) + if best_pos is None: + best_spread_pos = None + best_spread_dir = None + best_min_friend_dist = -1 + for (nr, nc), d in _cardinal_moves(r, c, rows, cols): + if (nr, nc) in wall_set or (nr, nc) in claimed: + continue + min_friend = min( + (_dist2(nr, nc, fr, fc, rows, cols) for fr, fc in my_positions if (fr, fc) != (r, c)), + default=999, + ) + if min_friend > best_min_friend_dist: + best_min_friend_dist = min_friend + best_spread_pos = (nr, nc) + best_spread_dir = d + if best_spread_pos: + best_pos = best_spread_pos + best_dir = best_spread_dir + + if best_pos and best_dir: + moves.append({"position": {"row": r, "col": c}, "direction": best_dir}) + claimed.add(best_pos) + else: + # Hold position + claimed.add((r, c)) + + return moves + + +# ── HTTP handler ───────────────────────────────────────────────────────────── + +SECRET = os.environ.get("BOT_SECRET", "") + + +class NomadHandler(BaseHTTPRequestHandler): + def _verify_signature(self, body: bytes) -> bool: + if not SECRET: + return True + sig = self.headers.get("X-ACB-Signature", "") + match_id = self.headers.get("X-ACB-Match-Id", "") + turn = self.headers.get("X-ACB-Turn", "") + ts = self.headers.get("X-ACB-Timestamp", "") + body_hash = hashlib.sha256(body).hexdigest() + signing = f"{match_id}.{turn}.{ts}.{body_hash}" + expected = hmac.new(SECRET.encode(), signing.encode(), hashlib.sha256).hexdigest() + return hmac.compare_digest(sig, expected) + + def _sign_response(self, match_id: str, turn: str, body: bytes) -> str: + if not SECRET: + return "" + body_hash = hashlib.sha256(body).hexdigest() + signing = f"{match_id}.{turn}.{body_hash}" + return hmac.new(SECRET.encode(), signing.encode(), hashlib.sha256).hexdigest() + + def do_GET(self): + if self.path == "/health": + self.send_response(200) + self.end_headers() + self.wfile.write(b"OK") + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + if self.path != "/turn": + self.send_response(404) + self.end_headers() + return + + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + + if not self._verify_signature(body): + self.send_response(401) + self.end_headers() + return + + raw = json.loads(body) + state = GameState(raw) + moves = compute_moves(state) + + resp = json.dumps({"moves": moves}).encode() + sig = self._sign_response( + self.headers.get("X-ACB-Match-Id", ""), + self.headers.get("X-ACB-Turn", ""), + resp, + ) + + self.send_response(200) + self.send_header("Content-Type", "application/json") + if sig: + self.send_header("X-ACB-Signature", sig) + self.end_headers() + self.wfile.write(resp) + + def log_message(self, _format, *args): + pass # silence request logs + + +def main(): + port = int(os.environ.get("BOT_PORT", "8080")) + server = HTTPServer(("0.0.0.0", port), NomadHandler) + print(f"NomadBot listening on :{port}") + server.serve_forever() + + +if __name__ == "__main__": + main()