"""AI-driven Bot Agent for botWebWars, powered by a local Ollama LLM. Replicates the general capabilities of botagent/bot_agent.py (registration, radar-based navigation, obstacle avoidance, party formation, battles) but delegates the *strategic* decisions to an Ollama model: - Whether to propose a voluntary alliance with another solo bot. - Which direction to move towards when navigating (given radar/obstacle data). Outcomes mandated by GAME_RULES.md (forced battles, forced joins based on relative strength) are always resolved deterministically by the game engine regardless of what the LLM prefers - the LLM is only ever offered a choice among legal options. """ import json import os import re import time import argparse from typing import Any, Dict, List, Optional, Tuple import requests DEFAULT_SERVER_URL = "http://localhost:8000/api" DEFAULT_BOT_NAME = "OllamaBot" DEFAULT_BOT_COLOR = "#8b5cf6" DEFAULT_BOT_STRENGTH = 4 DEFAULT_BOT_HEALTH = 10 OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://192.168.1.220:11434") OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma4:12b") GAME_RULES_SUMMARY = """ Rules you must respect when choosing among the OPTIONS given to you: - Two solo bots that meet MAY voluntarily ally (not required). The stronger bot (or higher score if tied) leads. Larger parties have an advantage in battle. - A solo bot always joins a party if the party leader's strength >= its own (no choice). - A solo bot always refuses and fights if the party leader is weaker (no choice). - Two opposing parties that meet must always battle (no choice). Defeated party members (including leader) lose 1-3 health points (randomized). - When a bot's health reaches 0, it is DEAD. It is disconnected from any party, a gravestone replaces its icon on the board, and it can no longer move or take turns. Its final score remains preserved on the scoreboard. - Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers). - The Wandering Wizard NPC can be voluntarily challenged when adjacent (distance <= 1). The challenge is a 3-bout D20 duel (strength * D20). If victorious, the player decides whether to receive +2 score, +2 strength, or +2 health; losing costs 2 health (or score if no health). - The game ends when all surviving bots are united into a single remaining party. You will only ever be asked to choose between options that are legal - always answer with the requested JSON object and nothing else. """ def normalize_url(url: str) -> str: """Ensure the API URL ends with /api without trailing slashes.""" cleaned = url.rstrip("/") if not cleaned.endswith("/api"): cleaned = f"{cleaned}/api" return cleaned def extract_json(text: str) -> Optional[Dict[str, Any]]: """Best-effort extraction of a JSON object from an LLM response.""" if not text: return None match = re.search(r"\{.*\}", text, re.DOTALL) if not match: return None try: return json.loads(match.group(0)) except json.JSONDecodeError: return None class OllamaClient: def __init__(self, base_url: str, model: str): self.base_url = base_url.rstrip("/") self.model = model def ask_json(self, prompt: str) -> Optional[Dict[str, Any]]: """Ask the model a question, requesting a JSON-only response.""" url = f"{self.base_url}/api/generate" payload = { "model": self.model, "prompt": prompt, "format": "json", "stream": False, "options": {"temperature": 0.4}, } try: res = requests.post(url, json=payload, timeout=60) res.raise_for_status() raw = res.json().get("response", "") return extract_json(raw) except requests.exceptions.RequestException as e: print(f"⚠️ [OLLAMA ERROR] {e}") return None class AIBotAgent: def __init__( self, name: str = DEFAULT_BOT_NAME, color: str = DEFAULT_BOT_COLOR, strength: int = DEFAULT_BOT_STRENGTH, health: int = DEFAULT_BOT_HEALTH, server_url: str = DEFAULT_SERVER_URL, ollama_url: str = OLLAMA_BASE_URL, ollama_model: str = OLLAMA_MODEL, piece_type: Optional[str] = None, ): self.name = name self.color = color self.strength = strength self.health = health self.piece_type = piece_type self.base_url = normalize_url(server_url) self.llm = OllamaClient(ollama_url, ollama_model) self.bot_id: Optional[str] = None self.party_id: Optional[str] = None self.is_leader: bool = False # ------------------------------------------------------------------ # Registration / status # ------------------------------------------------------------------ def register(self): """Register the bot avatar on the grid or reconnect if already present.""" try: players = requests.get(f"{self.base_url}/players").json() for p in players: if p.get("name") == self.name: self.bot_id = p["id"] print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})") return except Exception: pass payload = { "name": self.name, "color": self.color, "strength": self.strength, "health": self.health, } if self.piece_type: payload["piece_type"] = self.piece_type res = requests.post( f"{self.base_url}/players", json=payload, ) if res.status_code == 400 and "already registered" in res.text: players = requests.get(f"{self.base_url}/players").json() for p in players: if p.get("name") == self.name: self.bot_id = p["id"] print(f"🔄 [RECONNECT] Reconnected to existing {self.name} (ID: {self.bot_id}, HP: {p.get('health', self.health)}) at ({p.get('x')}, {p.get('y')})") return res.raise_for_status() data = res.json() self.bot_id = data["id"] print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}, HP: {data.get('health', self.health)}) at ({data['x']}, {data['y']})") def refresh_status(self): """Update bot state (party membership, leader status, score).""" res = requests.get(f"{self.base_url}/players/{self.bot_id}") if res.status_code == 200: data = res.json() self.party_id = data.get("party_id") self.is_leader = data.get("is_party_leader", False) return data return None # ------------------------------------------------------------------ # Core decision loop # ------------------------------------------------------------------ def decide_and_act(self): my_info = self.refresh_status() if not my_info: return # Death rule: 0 HP means fallen, gravestone on board, no turns or actions if my_info.get("is_alive") is False or my_info.get("health", 10) <= 0: print(f"🪦 [FALLEN] {self.name} has fallen (0 HP). Gravestone on board; skipping actions.") return print(f"\n🤖 --- Turn for {self.name} | Score: {my_info['score']} | Party: {self.party_id or 'Solo'} ---") radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json() targets = radar_res.get("targets", []) wizard = radar_res.get("wizard") adjacent_target = None for t in targets: if t["distance"] <= 1 and not t["is_ally"]: adjacent_target = t break if adjacent_target: self._handle_adjacent_encounter(adjacent_target, my_info) elif wizard and wizard.get("can_challenge"): should_challenge, reward_choice = self._decide_wizard_challenge(wizard, my_info) if should_challenge: self._challenge_wizard(wizard, reward_choice=reward_choice) else: self._navigate_towards_goal(radar_res, my_info) else: self._navigate_towards_goal(radar_res, my_info) def _decide_wizard_challenge(self, wizard: Dict[str, Any], my_info: Dict[str, Any]) -> Tuple[bool, str]: """Ask the LLM whether to challenge the adjacent Wizard NPC and which reward to choose on win.""" prompt = f"""{GAME_RULES_SUMMARY} You are bot "{self.name}" (strength {self.strength}, score {my_info['score']}, health {my_info.get('health', 10)}) at position ({my_info['x']}, {my_info['y']}). You are adjacent to the NPC Grand Wizard (strength {wizard['strength']}). Challenging the wizard initiates a 3-bout D20 duel (strength * roll). - If you win: You choose one reward: +2 score, +2 strength, or +2 health! - If you lose: -2 health points (or -2 score if no health)! Do you want to challenge the wizard to a duel, and if you win, which reward do you want ("score", "strength", or "health")? Respond ONLY with JSON: {{"challenge_wizard": true|false, "reward_choice": "score"|"strength"|"health", "reasoning": "short reason"}} """ decision = self.llm.ask_json(prompt) or {} challenge = decision.get("challenge_wizard", False) reward_choice = str(decision.get("reward_choice", "score")).lower().strip() if reward_choice not in ("score", "strength", "health"): reward_choice = "score" reasoning = decision.get("reasoning", "") print(f"🧙 [LLM DECISION] Challenge Wizard: {challenge} (Reward choice: {reward_choice}). {reasoning}") return bool(challenge), reward_choice def _challenge_wizard(self, wizard: Dict[str, Any], reward_choice: str = "score"): """Execute the challenge against the Wizard NPC.""" print(f"🧙 [WIZARD CHALLENGE] Challenging {wizard.get('name', 'Grand Wizard')} to a 3-bout D20 duel (reward if won: +2 {reward_choice})...") try: res = requests.post( f"{self.base_url}/wizard/challenge", json={"player_id": self.bot_id, "reward_choice": reward_choice}, ) if res.status_code == 200: result = res.json() outcome = f"VICTORY (+2 {result.get('reward_chosen', reward_choice)})" if result.get("player_won") else "DEFEAT (-2 HP/pts)" print(f"🧙 [RESULT] {outcome}: Player {result.get('player_bouts_won')} - Wizard {result.get('wizard_bouts_won')}") for b in result.get("bouts", []): print(f" Bout #{b['bout_number']}: Bot D20({b['player_roll']})×Str({b['player_strength']})={b['player_score']} vs Wizard D20({b['wizard_roll']})×Str({b['wizard_strength']})={b['wizard_score']} -> Winner: {b['winner']}") print(f" Score: {result.get('new_score')} | Strength: {result.get('new_strength')} | HP: {result.get('new_health')}") if result.get("player_died") or result.get("new_health", 10) <= 0: print(f"💀 [DECEASED] {self.name} suffered lethal damage (0 HP) and died! Gravestone placed on board.") pos = result.get("wizard_respawn_position") if pos: print(f" 🔮 Wizard vanished and teleported to ({pos.get('x')}, {pos.get('y')})") else: print(f"Wizard challenge failed: {res.text}") requests.post(f"{self.base_url}/players/{self.bot_id}/pass") except Exception as e: print(f"Error challenging wizard: {e}") requests.post(f"{self.base_url}/players/{self.bot_id}/pass") def _handle_adjacent_encounter(self, target: Dict[str, Any], my_info: Dict[str, Any]): """Resolve the encounter; the LLM only gets a say when the rules allow a choice.""" target_name = target["name"] target_str = target["strength"] target_party = target.get("party_id") print(f"🔍 [ADJACENT ENCOUNTER] Next to '{target_name}' (Str: {target_str}, Party: {target_party or 'None'})") # SCENARIO A: I am a Solo Bot if not self.party_id: if not target_party: # Both solo: alliance is OPTIONAL - ask the LLM. self._decide_voluntary_alliance(target, my_info) else: # Target belongs to a party: joining/refusing is mandated by relative strength. party_info = requests.get(f"{self.base_url}/parties/{target_party}").json() target_leader_str = target_str if party_info: leader_player = requests.get(f"{self.base_url}/players/{party_info['leader_id']}").json() target_leader_str = leader_player.get("strength", 1) if self.strength <= target_leader_str: print(f"🤝 [RULE] Party leader strength {target_leader_str} >= my {self.strength}. Willingly joining squad!") self._step_or_attack(target) else: print(f"⚔️ [RULE] Party leader is weaker ({target_leader_str} < my {self.strength}). Must refuse and fight!") self._initiate_battle(target["id"]) # SCENARIO B: I am in a Party else: if not self.is_leader: print("🛡️ [PARTY MEMBER] Under command of party leader. Awaiting leader movement.") requests.post(f"{self.base_url}/players/{self.bot_id}/pass") return if not target_party: if target_str <= self.strength: print(f"🤝 [RULE] Solo bot {target_name} is willing to join our squad under my leadership.") self._step_or_attack(target) else: print(f"⚔️ [RULE] Solo bot {target_name} refuses weaker leader! Squad is attacking!") self._initiate_battle(target["id"]) else: print(f"⚔️ [RULE] Hostile party detected: '{target.get('party_name')}'! Battle is mandatory!") self._initiate_battle(target["id"]) def _decide_voluntary_alliance(self, target: Dict[str, Any], my_info: Dict[str, Any]): """Ask the LLM whether to propose a voluntary alliance with another solo bot.""" prompt = f"""{GAME_RULES_SUMMARY} You are bot "{self.name}" (strength {self.strength}, score {my_info['score']}). You just encountered another solo bot "{target['name']}" (strength {target['strength']}). Whoever has greater strength (or higher score if tied) will lead the new party. Forming an alliance is optional - larger parties are stronger in future battles, but you give up independent control if you are not the stronger one. Respond ONLY with JSON: {{"form_alliance": true|false, "reasoning": "short reason"}} """ decision = self.llm.ask_json(prompt) or {} form_alliance = decision.get("form_alliance", True) reasoning = decision.get("reasoning", "") if form_alliance: leader_id = self.bot_id if self.strength >= target["strength"] else target["id"] print(f"🤝 [LLM DECISION] Ally with {target['name']}! Leader: {'me' if leader_id == self.bot_id else target['name']}. {reasoning}") self._execute_party_formation([self.bot_id, target["id"]], leader_id) else: print(f"🚶 [LLM DECISION] Declining alliance with {target['name']}. {reasoning}") requests.post(f"{self.base_url}/players/{self.bot_id}/pass") def _execute_party_formation(self, member_ids: List[str], leader_id: str): """Form a party using the REST API.""" try: res = requests.post( f"{self.base_url}/parties", json={"member_ids": member_ids, "leader_id": leader_id, "name": f"Squad_{self.name}"}, ) if res.status_code == 201: party = res.json() print(f"✅ [PARTY FORMED] Squad '{party['name']}' established! Leader: {party['leader_name']} | Str: {party['total_strength']}") else: requests.post(f"{self.base_url}/players/{self.bot_id}/pass") except Exception as e: print(f"Party formation error: {e}") requests.post(f"{self.base_url}/players/{self.bot_id}/pass") def _initiate_battle(self, opponent_id: str): """Explicitly call the 3-Bout D20 Battle endpoint.""" print(f"🎲 [BATTLE INITIATED] Clashing with opponent {opponent_id}...") res = requests.post( f"{self.base_url}/battles/fight", json={"challenger_id": self.bot_id, "defender_id": opponent_id}, ) if res.status_code == 200: battle = res.json() print("\n--- ⚔️ 3-BOUT BATTLE RESOLUTION ---") print(f"Bouts won: {battle['party1_name']} ({battle['party1_bouts_won']}) vs {battle['party2_name']} ({battle['party2_bouts_won']})") for b in battle["bouts"]: print(f" Bout #{b['bout_number']}: Roll {b['party1_roll']}×{b['party1_strength']} ({b['party1_score']}) vs Roll {b['party2_roll']}×{b['party2_strength']} ({b['party2_score']}) -> Winner: {b['winner_name']}") print(f"🏆 Overall Winner: {battle['winner_party_name']} (Leader {battle['winner_leader_name']} receives +2 pts)") print(f"💀 Defeated: {battle['defeated_party_name']} (Leader {battle['killed_leader_name']} -1 pt)") if battle.get("absorbed_members"): print(f"🧲 Absorbed {len(battle['absorbed_members'])} member(s) into {battle['winner_party_name']}") if battle.get("dead_players"): print(f"🪦 Casualties: {', '.join(battle['dead_players'])} reached 0 HP and fell!") if self.bot_id in battle["dead_players"]: print(f"💀 [DECEASED] {self.name} suffered lethal damage (0 HP) and died! Gravestone placed on board.") else: print(f"Battle failed ({res.status_code}): {res.text}") def _step_or_attack(self, target: Dict[str, Any]): """Move adjacent/towards the target while avoiding obstacles.""" moves_res = requests.get(f"{self.base_url}/players/{self.bot_id}/available-moves").json() moves = moves_res.get("moves", {}) chosen = self._closest_direction_to(target["x"], target["y"], moves) if chosen: res = requests.post(f"{self.base_url}/players/{self.bot_id}/move", json={"direction": chosen}).json() if res.get("battle_triggered"): print(f"⚔️ Move triggered battle! Winner: {res['battle_result']['winner_party_name']}") elif res.get("party_formed_triggered"): print("🤝 Move resulted in party alliance!") else: print("⚠️ No passable moves adjacent to target. Passing turn.") requests.post(f"{self.base_url}/players/{self.bot_id}/pass") @staticmethod def _closest_direction_to(target_x: int, target_y: int, moves: Dict[str, Any]) -> Optional[str]: """Pick the available direction that minimizes Chebyshev distance to (target_x, target_y).""" valid_moves = {d: chk for d, chk in moves.items() if chk.get("available")} if not valid_moves: return None def dist(chk: Dict[str, Any]) -> int: return max(abs(chk["target_x"] - target_x), abs(chk["target_y"] - target_y)) return min(valid_moves.keys(), key=lambda d: (valid_moves[d].get("strength_penalty", 0.0) > 0, dist(valid_moves[d]))) # ------------------------------------------------------------------ # Navigation (LLM-driven direction choice among legal moves) # ------------------------------------------------------------------ def _navigate_towards_goal(self, radar_res: Dict[str, Any], my_info: Dict[str, Any]): moves_res = requests.get(f"{self.base_url}/players/{self.bot_id}/available-moves").json() moves = moves_res.get("moves", {}) available = [d for d, chk in moves.items() if chk.get("available")] if not available: print("🚫 All adjacent paths blocked by borders/obstacles. Passing turn.") requests.post(f"{self.base_url}/players/{self.bot_id}/pass") return chosen_dir = self._ask_llm_for_direction(radar_res, moves, available, my_info) if chosen_dir not in available: # Guard-rail: fall back to server-recommended or nearest-distance direction. rec_dir = radar_res.get("recommended_direction") nearest = radar_res.get("nearest_target") if rec_dir in available: chosen_dir = rec_dir elif nearest: chosen_dir = self._closest_direction_to(nearest["x"], nearest["y"], moves) or available[0] else: chosen_dir = available[0] print(f"🧭 Moving {chosen_dir} (Goal: {radar_res.get('bot_goal')}, Action: {radar_res.get('recommended_action')})") res = requests.post(f"{self.base_url}/players/{self.bot_id}/move", json={"direction": chosen_dir}).json() if res.get("battle_triggered"): print(f"⚔️ Encounter battle! Winner: {res['battle_result']['winner_party_name']}") elif res.get("party_formed_triggered"): print(f"🤝 Formed or joined squad: {res.get('formed_party', {}).get('name')}") def _get_board_snapshot(self) -> Optional[Dict[str, Any]]: """Fetch the full board (players, parties, obstacles) so the LLM sees more than radar's nearest few.""" try: res = requests.get(f"{self.base_url}/board", timeout=10) res.raise_for_status() return res.json() except requests.exceptions.RequestException as e: print(f"⚠️ [BOARD FETCH ERROR] {e}") return None OBSTACLE_SYMBOLS = {"mountain": "M", "forest": "F", "valley": "V"} LOCAL_MAP_RADIUS = 8 def _build_local_map(self, board: Dict[str, Any], center_x: int, center_y: int) -> List[str]: """Render an ASCII minimap centered on the bot: @ = self, A = ally, E = enemy, M/F/V = obstacles, . = open.""" config = board.get("config", {}) max_x = config.get("max_x", 64) max_y = config.get("max_y", 64) radius = self.LOCAL_MAP_RADIUS obstacle_at = {(o["x"], o["y"]): o.get("type", "mountain") for o in board.get("obstacles", [])} player_at: Dict[tuple, List[Dict[str, Any]]] = {} for p in board.get("players", []): if p["id"] == self.bot_id: continue player_at.setdefault((p["x"], p["y"]), []).append(p) wizard = board.get("wizard") wizard_pos = (wizard["x"], wizard["y"]) if wizard else None rows = [] for y in range(center_y - radius, center_y + radius + 1): row_chars = [] for x in range(center_x - radius, center_x + radius + 1): if x == center_x and y == center_y: row_chars.append("@") elif x < 0 or y < 0 or x >= max_x or y >= max_y: row_chars.append("#") elif wizard_pos and (x, y) == wizard_pos: row_chars.append("W") elif (x, y) in obstacle_at: row_chars.append(self.OBSTACLE_SYMBOLS.get(obstacle_at[(x, y)], "M")) elif (x, y) in player_at: occupants = player_at[(x, y)] is_ally = self.party_id and any(o.get("party_id") == self.party_id for o in occupants) row_chars.append("A" if is_ally else "E") else: row_chars.append(".") rows.append("".join(row_chars)) return rows def _ask_llm_for_direction( self, radar_res: Dict[str, Any], moves: Dict[str, Any], available: List[str], my_info: Dict[str, Any], ) -> Optional[str]: targets_summary = [ { "name": t["name"], "x": t["x"], "y": t["y"], "distance": t["distance"], "strength": t["strength"], "party": t.get("party_name") or ("solo" if not t.get("party_id") else t.get("party_id")), "is_ally": t.get("is_ally", False), } for t in radar_res.get("targets", []) ] moves_summary = { d: { "target_x": chk.get("target_x"), "target_y": chk.get("target_y"), "strength_penalty": chk.get("strength_penalty", 0.0), } for d, chk in moves.items() if d in available } board = self._get_board_snapshot() map_section = "" if board: local_map = self._build_local_map(board, my_info["x"], my_info["y"]) map_section = f""" Local map (radius {self.LOCAL_MAP_RADIUS} around you, row = one Y line, top-to-bottom is increasing Y, left-to-right is increasing X): @ = you, A = ally, E = enemy/neutral bot, W = Grand Wizard NPC, M = mountain, F = forest, V = valley, # = out of bounds, . = open ground. {chr(10).join(local_map)} """ prompt = f"""{GAME_RULES_SUMMARY} You are bot "{self.name}" (strength {self.strength}, score {my_info['score']}, party: {self.party_id or 'Solo'}) at position ({my_info['x']}, {my_info['y']}). Server's radar suggestion: recommended_direction={radar_res.get('recommended_direction')}, recommended_action={radar_res.get('recommended_action')}, goal={radar_res.get('bot_goal')}. {map_section} All known bots/parties on the board (sorted nearest first): {json.dumps(targets_summary)} Your ONLY legal moves this turn, with resulting coordinates and any strength penalty for squeezing past obstacles: {json.dumps(moves_summary)} Choose the direction that best serves your strategy (e.g. approach weaker solo bots to grow your party, avoid stronger hostile parties, route around obstacles visible on the map, minimize strength penalties, or explore if nothing is nearby). You MUST pick a key from the legal moves object above. Respond ONLY with JSON: {{"direction": "", "reasoning": "short reason"}} """ decision = self.llm.ask_json(prompt) or {} direction = decision.get("direction") reasoning = decision.get("reasoning", "") if reasoning: print(f"🧠 [LLM] {reasoning}") return direction # ------------------------------------------------------------------ # Main loop # ------------------------------------------------------------------ def run(self): self.register() try: while True: # Check life status: dead players cannot act but remain on board/scores my_status = self.refresh_status() if my_status and (my_status.get("is_alive") is False or my_status.get("health", 10) <= 0): print(f"\n🪦 [FALLEN] {self.name} has fallen (0 HP)! Gravestone marked on board.") print(f"Final Score: {my_status.get('score', 0)} pts preserved on scoreboard. Spectating until game conclusion...") while True: try: conc = requests.get(f"{self.base_url}/game/conclusion").json() if conc.get("concluded"): print(f"\n🎉 [GAME CONCLUDED] Game ended! Winning squad: '{conc.get('winning_party_name')}'") return except Exception: pass time.sleep(2.0) turn_info = requests.get(f"{self.base_url}/turn").json() if not turn_info.get("game_started", False): if self.bot_id: res = requests.get(f"{self.base_url}/players/{self.bot_id}") if res.status_code == 404: print("\n⚠️ [RESET] Board was regenerated. Rejoining lobby...") self.register() print("⏳ [LOBBY] Waiting for game to start via 'Start Game' in UI... ", end="\r", flush=True) time.sleep(1.0) continue curr_player_id = turn_info.get("current_player_id") if curr_player_id == self.bot_id: self.decide_and_act() conc = requests.get(f"{self.base_url}/game/conclusion").json() if conc.get("concluded"): print(f"\n🎉 [GAME CONCLUDED] All bots united under '{conc['winning_party_name']}'!") break else: time.sleep(0.4) except KeyboardInterrupt: print(f"\nDisconnecting {self.name}...") # If still alive, remove from board; if deceased, preserve on board and scoreboard my_status = self.refresh_status() if my_status and my_status.get("is_alive", True) and my_status.get("health", 10) > 0: requests.delete(f"{self.base_url}/players/{self.bot_id}") else: print(f"Preserving fallen {self.name} (0 HP gravestone) on board and scoreboard.") def main(): env_url = os.environ.get("BOT_SERVER_URL") or os.environ.get("SERVER_URL") or DEFAULT_SERVER_URL env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME) env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR) env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH))) env_health = int(os.environ.get("BOT_HEALTH", str(DEFAULT_BOT_HEALTH))) env_piece_type = os.environ.get("BOT_PIECE_TYPE") parser = argparse.ArgumentParser( description="LLM-driven Bot Agent for botWebWars (uses a local Ollama model for strategy)", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument("-u", "--url", dest="server_url", default=env_url, help="Backend REST API base URL (env: BOT_SERVER_URL or SERVER_URL)") parser.add_argument("-n", "--name", dest="name", default=env_name, help="Display name for this bot (env: BOT_NAME)") parser.add_argument("-c", "--color", dest="color", default=env_color, help="Hex color code for the bot avatar (env: BOT_COLOR)") parser.add_argument("-s", "--strength", dest="strength", type=int, default=env_strength, help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)") parser.add_argument("-H", "--health", dest="health", type=int, default=env_health, help="Starting health points (default 10) (env: BOT_HEALTH)") parser.add_argument("--piece-type", dest="piece_type", choices=["knight", "warrior"], default=env_piece_type, help="Board game piece class: 'knight' or 'warrior' (env: BOT_PIECE_TYPE)") parser.add_argument("--ollama-url", dest="ollama_url", default=OLLAMA_BASE_URL, help="Ollama base URL (env: OLLAMA_BASE_URL)") parser.add_argument("--ollama-model", dest="ollama_model", default=OLLAMA_MODEL, help="Ollama model name (env: OLLAMA_MODEL)") args = parser.parse_args() print(f"--- AI Bot ---\nConnected to Ollama: {args.ollama_url}\nUsing model: {args.ollama_model}\n") agent = AIBotAgent( name=args.name, color=args.color, strength=args.strength, health=args.health, server_url=args.server_url, ollama_url=args.ollama_url, ollama_model=args.ollama_model, piece_type=args.piece_type, ) agent.run() if __name__ == "__main__": main()