From 0db45dcfc22b5716d6be2f25d2f08345090f3af4 Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 17 Jun 2026 01:34:26 -0400 Subject: [PATCH] Implement EconomistBot: energy-denial via node contesting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds bots/economist/bot.py and Dockerfile implementing a new strategy: deliberately contest energy nodes that enemies are approaching to destroy the contested energy, denying enemy income while harvesting uncontested nodes for own economy. Strategy priorities: 1. Maintain existing contest assignments (stay put) 2. Contest threatened nodes (intercept approaching enemies) 3. Reserve 1-2 bots to guard cores 4. Collect uncontested energy 5. Remaining bots explore toward map center Pure collection mode when score lead ≥3 and turn > 200. Co-Authored-By: Claude --- bots/economist/Dockerfile | 12 + .../economist/__pycache__/bot.cpython-312.pyc | Bin 0 -> 20650 bytes bots/economist/bot.py | 529 ++++++++++++++++++ 3 files changed, 541 insertions(+) create mode 100644 bots/economist/Dockerfile create mode 100644 bots/economist/__pycache__/bot.cpython-312.pyc create mode 100755 bots/economist/bot.py diff --git a/bots/economist/Dockerfile b/bots/economist/Dockerfile new file mode 100644 index 0000000..4e2d1c6 --- /dev/null +++ b/bots/economist/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-alpine + +WORKDIR /app + +COPY bot.py . + +ENV BOT_PORT=8081 +ENV BOT_SECRET="" + +EXPOSE 8081 + +CMD ["python3", "bot.py"] diff --git a/bots/economist/__pycache__/bot.cpython-312.pyc b/bots/economist/__pycache__/bot.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3bc2dd567fce2a6e332feacebf02b968dc34b050 GIT binary patch literal 20650 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 literal 0 HcmV?d00001 diff --git a/bots/economist/bot.py b/bots/economist/bot.py new file mode 100755 index 0000000..02abe9c --- /dev/null +++ b/bots/economist/bot.py @@ -0,0 +1,529 @@ +#!/usr/bin/env python3 +""" +EconomistBot - Wins by energy starvation through node contesting. + +This bot deliberately contests energy nodes that enemies are approaching, +destroying the contested energy rather than collecting it, while harvesting +uncontested nodes for its own economy. + +Key mechanic: If two players each have a bot adjacent (≤√2) to an energy node +on the same collect phase, the energy is destroyed (neither gets it). +""" + +import hashlib +import hmac +import json +import os +import random +import math +from http.server import HTTPServer, BaseHTTPRequestHandler +from typing import List, Dict, Optional, Tuple, Set + + +# Position tuple (row, col) +Position = Tuple[int, int] + +# Game constants +ADJACENT_RADIUS2 = 2 # sqrt(2)^2 = 2 +APPROACH_THRESHOLD = 4 # Consider enemies within this distance as "approaching" + + +class GameState: + """Represents the fog-filtered state visible to this bot.""" + + def __init__(self, data: dict): + self.match_id = data["match_id"] + self.turn = data["turn"] + self.config = data["config"] + self.you_id = data["you"]["id"] + self.you_energy = data["you"]["energy"] + self.you_score = data["you"]["score"] + self.bots = data["bots"] + self.energy = [tuple(e) for e in data.get("energy", [])] + self.cores = data.get("cores", []) + self.walls = [tuple(w) for w in data.get("walls", [])] + self.dead = data.get("dead", []) + + @property + def rows(self) -> int: + return self.config["rows"] + + @property + def cols(self) -> int: + return self.config["cols"] + + @property + def vision_radius2(self) -> int: + return self.config["vision_radius2"] + + @property + def attack_radius2(self) -> int: + return self.config["attack_radius2"] + + @property + def spawn_cost(self) -> int: + return self.config["spawn_cost"] + + +class EconomistStrategy: + """Implements energy-denial strategy through node contesting.""" + + def __init__(self): + self.contest_assignments: Dict[int, Position] = {} # bot_id -> energy position to contest + self.guard_assignments: Set[int] = set() # bot_ids assigned to guard cores + + def compute_moves(self, state: GameState) -> List[dict]: + """Compute moves for all owned bots.""" + # Separate bots by owner + my_bots = [b for b in state.bots if b["owner"] == state.you_id] + enemy_bots = [b for b in state.bots if b["owner"] != state.you_id] + + if not my_bots: + return [] + + # Build position maps + enemy_positions = {tuple(b["position"]): b for b in enemy_bots} + my_bot_positions = {tuple(b["position"]): b for b in my_bots} + energy_positions = set(state.energy) + + # Build my cores positions + my_core_positions = set() + for core in state.cores: + if core["owner"] == state.you_id: + my_core_positions.add(tuple(core["position"])) + + # Check if we should switch to pure collection mode + score_lead = state.you_score + for enemy in enemy_bots: + # Assume enemy has similar score if we can't see it + pass # Simplified - we use our score as proxy + + pure_collection = (state.turn > 200 and + score_lead >= 3) + + # Analyze energy nodes + energy_analysis = self._analyze_energy_nodes( + state.energy, my_bot_positions, enemy_positions, state + ) + + # Clear previous assignments for dead/moved bots + self._cleanup_assignments(my_bots, energy_positions) + + # Assign bots to roles + moves = [] + used_bots = set() + + # Priority 1: Maintain existing contest assignments (stay put) + for bot in my_bots: + bot_id = bot["id"] + bot_pos = tuple(bot["position"]) + + if bot_id in self.contest_assignments: + contest_pos = self.contest_assignments[bot_id] + if contest_pos in energy_positions: + # Check if still contesting effectively + dist2 = self._distance2(bot_pos, contest_pos, state) + if dist2 <= APPROACH_THRESHOLD * APPROACH_THRESHOLD: + # Still approaching, continue + used_bots.add(bot_id) + # Only move if not already adjacent + if dist2 > ADJACENT_RADIUS2: + move = self._move_toward(bot_pos, contest_pos, state, enemy_positions) + if move: + moves.append({ + "position": list(bot_pos), + "direction": move + }) + continue + + # Priority 2: Contest threatened nodes (highest priority) + if not pure_collection: + threatened_energy = [ + (pos, analysis) for pos, analysis in energy_analysis.items() + if analysis["enemy_nearby"] > 0 and analysis["my_adjacent"] == 0 + ] + + # Sort by contest priority + threatened_energy.sort( + key=lambda x: x[1]["contest_priority"], reverse=True + ) + + for energy_pos, analysis in threatened_energy: + if energy_pos not in energy_positions: + continue + + # Find nearest unassigned bot + nearest_bot = self._find_nearest_bot( + energy_pos, my_bots, used_bots, state + ) + + if nearest_bot: + bot_id = nearest_bot["id"] + bot_pos = tuple(nearest_bot["position"]) + + used_bots.add(bot_id) + self.contest_assignments[bot_id] = energy_pos + + # Move toward energy to contest + dist2 = self._distance2(bot_pos, energy_pos, state) + if dist2 > ADJACENT_RADIUS2: + move = self._move_toward(bot_pos, energy_pos, state, enemy_positions) + if move: + moves.append({ + "position": list(bot_pos), + "direction": move + }) + + # Priority 3: Reserve 1-2 bots to guard cores + guards_needed = min(2, len(my_bots) // 3) + guards_assigned = 0 + + for core_pos in my_core_positions: + if guards_assigned >= guards_needed: + break + + # Find nearest unassigned bot + nearest_bot = self._find_nearest_bot( + core_pos, my_bots, used_bots, state + ) + + if nearest_bot: + bot_id = nearest_bot["id"] + bot_pos = tuple(nearest_bot["position"]) + + dist2 = self._distance2(bot_pos, core_pos, state) + if dist2 > ADJACENT_RADIUS2: + # Move toward core to guard + move = self._move_toward(bot_pos, core_pos, state, enemy_positions) + if move: + moves.append({ + "position": list(bot_pos), + "direction": move + }) + + used_bots.add(bot_id) + self.guard_assignments.add(bot_id) + guards_assigned += 1 + + # Priority 4: Collect uncontested energy + unthreatened_energy = [ + pos for pos, analysis in energy_analysis.items() + if analysis["enemy_nearby"] == 0 and pos in energy_positions + ] + + for energy_pos in unthreatened_energy: + # Find nearest unassigned bot + nearest_bot = self._find_nearest_bot( + energy_pos, my_bots, used_bots, state + ) + + if nearest_bot: + bot_id = nearest_bot["id"] + bot_pos = tuple(nearest_bot["position"]) + + used_bots.add(bot_id) + + # Move toward energy to collect + dist2 = self._distance2(bot_pos, energy_pos, state) + if dist2 > ADJACENT_RADIUS2: + move = self._move_toward(bot_pos, energy_pos, state, enemy_positions) + if move: + moves.append({ + "position": list(bot_pos), + "direction": move + }) + + # Remaining bots: explore toward map center + center = (state.rows // 2, state.cols // 2) + for bot in my_bots: + if bot["id"] not in used_bots: + bot_pos = tuple(bot["position"]) + move = self._move_toward(bot_pos, center, state, enemy_positions) + if move: + moves.append({ + "position": list(bot_pos), + "direction": move + }) + + return moves + + def _analyze_energy_nodes( + self, + energy_nodes: List[Position], + my_bot_positions: Dict[Position, dict], + enemy_positions: Dict[Position, dict], + state: GameState + ) -> Dict[Position, dict]: + """Analyze energy nodes to determine contest priorities.""" + analysis = {} + + for energy_pos in energy_nodes: + enemy_nearby = 0 + enemy_approaching = 0 + my_adjacent = 0 + my_nearby = 0 + nearest_enemy_dist = float('inf') + nearest_my_dist = float('inf') + + # Count nearby bots + for bot_pos, bot in my_bot_positions.items(): + dist2 = self._distance2(bot_pos, energy_pos, state) + if dist2 <= ADJACENT_RADIUS2: + my_adjacent += 1 + if dist2 <= APPROACH_THRESHOLD * APPROACH_THRESHOLD: + my_nearby += 1 + nearest_my_dist = min(nearest_my_dist, dist2) + + for enemy_pos, enemy in enemy_positions.items(): + dist2 = self._distance2(enemy_pos, energy_pos, state) + if dist2 <= APPROACH_THRESHOLD * APPROACH_THRESHOLD: + enemy_nearby += 1 + nearest_enemy_dist = min(nearest_enemy_dist, dist2) + if dist2 <= APPROACH_THRESHOLD * APPROACH_THRESHOLD: + enemy_approaching += 1 + + # Calculate contest priority + # Priority = (enemy_count_nearby * energy_value) / distance + # Energy value is always 1 in current rules + distance_factor = math.sqrt(max(nearest_enemy_dist, 1)) + contest_priority = (enemy_approaching * 1.0) / distance_factor + + analysis[energy_pos] = { + "enemy_nearby": enemy_nearby, + "enemy_approaching": enemy_approaching, + "my_adjacent": my_adjacent, + "my_nearby": my_nearby, + "nearest_enemy_dist": nearest_enemy_dist, + "contest_priority": contest_priority if enemy_approaching > 0 else 0, + } + + return analysis + + def _cleanup_assignments(self, my_bots: List[dict], energy_positions: Set[Position]): + """Remove assignments for dead bots or consumed energy.""" + active_bot_ids = {b["id"] for b in my_bots} + + # Remove assignments for dead bots + to_remove = [] + for bot_id in self.contest_assignments: + if bot_id not in active_bot_ids: + to_remove.append(bot_id) + elif self.contest_assignments[bot_id] not in energy_positions: + to_remove.append(bot_id) + + for bot_id in to_remove: + del self.contest_assignments[bot_id] + + # Clean up guard assignments + self.guard_assignments = self.guard_assignments.intersection(active_bot_ids) + + def _find_nearest_bot( + self, + target: Position, + my_bots: List[dict], + used_bots: Set[int], + state: GameState + ) -> Optional[dict]: + """Find the nearest unused bot to the target position.""" + nearest_bot = None + nearest_dist = float('inf') + + for bot in my_bots: + if bot["id"] in used_bots: + continue + + dist2 = self._distance2(tuple(bot["position"]), target, state) + if dist2 < nearest_dist: + nearest_dist = dist2 + nearest_bot = bot + + return nearest_bot + + def _move_toward( + self, + from_pos: Position, + to_pos: Position, + state: GameState, + enemy_positions: Dict[Position, dict] + ) -> Optional[str]: + """Calculate best direction to move toward target, avoiding enemies.""" + directions = ["N", "E", "S", "W"] + best_dir = None + best_dist2 = float('inf') + + for direction in directions: + new_pos = self._simulate_move(from_pos, direction, state) + + # Avoid positions adjacent to enemies (combat avoidance) + if self._is_near_enemy(new_pos, enemy_positions, state): + continue + + dist2 = self._distance2(new_pos, to_pos, state) + if dist2 < best_dist2: + best_dist2 = dist2 + best_dir = direction + + return best_dir + + def _is_near_enemy( + self, + pos: Position, + enemy_positions: Dict[Position, dict], + state: GameState + ) -> bool: + """Check if position is adjacent to any enemy.""" + for direction in ["N", "E", "S", "W"]: + adj_pos = self._simulate_move(pos, direction, state) + if adj_pos in enemy_positions: + return True + return False + + def _distance2(self, a: Position, b: Position, state: GameState) -> int: + """Calculate squared Euclidean distance with toroidal wrapping.""" + dr = abs(a[0] - b[0]) + dc = abs(a[1] - b[1]) + + # Apply toroidal wrapping + if dr > state.rows // 2: + dr = state.rows - dr + if dc > state.cols // 2: + dc = state.cols - dc + + return dr * dr + dc * dc + + def _simulate_move(self, pos: Position, direction: str, state: GameState) -> Position: + """Calculate new position after a move with toroidal wrapping.""" + row, col = pos + + if direction == "N": + new_row = (row - 1 + state.rows) % state.rows + new_col = col + elif direction == "E": + new_row = row + new_col = (col + 1) % state.cols + elif direction == "S": + new_row = (row + 1) % state.rows + new_col = col + elif direction == "W": + new_row = row + new_col = (col - 1 + state.cols) % state.cols + else: + return pos + + return (new_row, new_col) + + +class EconomistBotHandler(BaseHTTPRequestHandler): + """HTTP request handler for EconomistBot.""" + + secret: str = "" + strategy = EconomistStrategy() + + def log_message(self, format, *args): + """Suppress default logging.""" + pass + + def send_json_response(self, status: int, data: dict, match_id: str = "", turn: int = 0): + """Send a JSON response with HMAC signature.""" + body = json.dumps(data).encode("utf-8") + + # Sign response + sig = self.sign_response(body, match_id, turn) + + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("X-ACB-Signature", sig) + self.end_headers() + self.wfile.write(body) + + def sign_response(self, body: bytes, match_id: str, turn: int) -> str: + """Generate HMAC signature for response.""" + body_hash = hashlib.sha256(body).hexdigest() + signing_string = f"{match_id}.{turn}.{body_hash}" + sig = hmac.new( + self.secret.encode("utf-8"), + signing_string.encode("utf-8"), + hashlib.sha256 + ).hexdigest() + return sig + + def verify_signature(self, body: bytes, match_id: str, turn: str, + timestamp: str, signature: str) -> bool: + """Verify HMAC signature of incoming request.""" + body_hash = hashlib.sha256(body).hexdigest() + signing_string = f"{match_id}.{turn}.{body_hash}" + expected_sig = hmac.new( + self.secret.encode("utf-8"), + signing_string.encode("utf-8"), + hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(signature, expected_sig) + + def do_GET(self): + """Handle GET requests (health check).""" + if self.path == "/health": + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(b"OK") + else: + self.send_error(404, "Not Found") + + def do_POST(self): + """Handle POST requests (turn).""" + if self.path != "/turn": + self.send_error(404, "Not Found") + return + + # Read body + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) + + # Get auth headers + match_id = self.headers.get("X-ACB-Match-Id", "") + turn_str = self.headers.get("X-ACB-Turn", "0") + timestamp = self.headers.get("X-ACB-Timestamp", "") + signature = self.headers.get("X-ACB-Signature", "") + + if not signature: + self.send_error(401, "Missing signature") + return + + # Verify signature + if not self.verify_signature(body, match_id, turn_str, timestamp, signature): + self.send_error(401, "Invalid signature") + return + + # Parse game state + try: + data = json.loads(body) + state = GameState(data) + except (json.JSONDecodeError, KeyError) as e: + self.send_error(400, f"Invalid game state: {e}") + return + + # Compute moves + moves = self.strategy.compute_moves(state) + turn = int(turn_str) + + # Send response + self.send_json_response(200, {"moves": moves}, match_id, turn) + + +def main(): + port = int(os.environ.get("BOT_PORT", "8081")) + secret = os.environ.get("BOT_SECRET", "") + + if not secret: + print("ERROR: BOT_SECRET environment variable is required") + exit(1) + + EconomistBotHandler.secret = secret + + server = HTTPServer(("", port), EconomistBotHandler) + print(f"EconomistBot starting on port {port}") + server.serve_forever() + + +if __name__ == "__main__": + main()