feat(bot): add Nomad bot (Python) — constant relocation archetype
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 <noreply@anthropic.com>
This commit is contained in:
parent
53377d577f
commit
2df70c8ae0
3 changed files with 554 additions and 0 deletions
11
bots/nomad/Dockerfile
Normal file
11
bots/nomad/Dockerfile
Normal file
|
|
@ -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"]
|
||||
64
bots/nomad/grid.py
Normal file
64
bots/nomad/grid.py
Normal file
|
|
@ -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
|
||||
479
bots/nomad/main.py
Normal file
479
bots/nomad/main.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Reference in a new issue