botWebWars/botagent_ai/bot.py

649 lines
31 KiB
Python
Raw Permalink Normal View History

2026-09-06 18:56:51 +00:00
"""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
2026-09-06 18:56:51 +00:00
import requests
DEFAULT_SERVER_URL = "http://localhost:8000/api"
DEFAULT_BOT_NAME = "OllamaBot"
DEFAULT_BOT_COLOR = "#8b5cf6"
DEFAULT_BOT_STRENGTH = 4
2026-09-09 22:11:54 +00:00
DEFAULT_BOT_HEALTH = 10
2026-09-06 18:56:51 +00:00
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).
2026-09-09 22:11:54 +00:00
- 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.
2026-09-06 18:56:51 +00:00
- Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers).
2026-09-09 22:11:54 +00:00
- 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.
2026-09-06 18:56:51 +00:00
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,
2026-09-09 22:11:54 +00:00
health: int = DEFAULT_BOT_HEALTH,
2026-09-06 18:56:51 +00:00
server_url: str = DEFAULT_SERVER_URL,
ollama_url: str = OLLAMA_BASE_URL,
ollama_model: str = OLLAMA_MODEL,
2026-09-09 20:41:06 +00:00
piece_type: Optional[str] = None,
2026-09-06 18:56:51 +00:00
):
self.name = name
self.color = color
self.strength = strength
2026-09-09 22:11:54 +00:00
self.health = health
2026-09-09 20:41:06 +00:00
self.piece_type = piece_type
2026-09-06 18:56:51 +00:00
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"]
2026-09-09 22:11:54 +00:00
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')})")
2026-09-06 18:56:51 +00:00
return
except Exception:
pass
2026-09-09 22:11:54 +00:00
payload = {
"name": self.name,
"color": self.color,
"strength": self.strength,
"health": self.health,
}
2026-09-09 20:41:06 +00:00
if self.piece_type:
payload["piece_type"] = self.piece_type
2026-09-06 18:56:51 +00:00
res = requests.post(
f"{self.base_url}/players",
2026-09-09 20:41:06 +00:00
json=payload,
2026-09-06 18:56:51 +00:00
)
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"]
2026-09-09 22:11:54 +00:00
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')})")
2026-09-06 18:56:51 +00:00
return
res.raise_for_status()
data = res.json()
self.bot_id = data["id"]
2026-09-09 22:11:54 +00:00
print(f"🚀 [REGISTER] Spawned {self.name} (ID: {self.bot_id}, Str: {self.strength}, HP: {data.get('health', self.health)}) at ({data['x']}, {data['y']})")
2026-09-06 18:56:51 +00:00
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
2026-09-06 18:56:51 +00:00
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", [])
2026-09-09 22:11:54 +00:00
wizard = radar_res.get("wizard")
2026-09-06 18:56:51 +00:00
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)
2026-09-09 22:11:54 +00:00
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)
2026-09-09 22:11:54 +00:00
else:
self._navigate_towards_goal(radar_res, my_info)
2026-09-06 18:56:51 +00:00
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."""
2026-09-09 22:11:54 +00:00
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']}).
2026-09-10 00:46:30 +00:00
You are adjacent to the NPC Gary the Wizard (strength {wizard['strength']}).
2026-09-09 22:11:54 +00:00
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!
2026-09-09 22:11:54 +00:00
- 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")?
2026-09-09 22:11:54 +00:00
Respond ONLY with JSON: {{"challenge_wizard": true|false, "reward_choice": "score"|"strength"|"health", "reasoning": "short reason"}}
2026-09-09 22:11:54 +00:00
"""
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"
2026-09-09 22:11:54 +00:00
reasoning = decision.get("reasoning", "")
print(f"🧙 [LLM DECISION] Challenge Wizard: {challenge} (Reward choice: {reward_choice}). {reasoning}")
return bool(challenge), reward_choice
2026-09-09 22:11:54 +00:00
def _challenge_wizard(self, wizard: Dict[str, Any], reward_choice: str = "score"):
2026-09-09 22:11:54 +00:00
"""Execute the challenge against the Wizard NPC."""
2026-09-10 00:46:30 +00:00
print(f"🧙 [WIZARD CHALLENGE] Challenging {wizard.get('name', 'Gary the Wizard')} to a 3-bout D20 duel (reward if won: +2 {reward_choice})...")
2026-09-09 22:11:54 +00:00
try:
res = requests.post(
f"{self.base_url}/wizard/challenge",
json={"player_id": self.bot_id, "reward_choice": reward_choice},
2026-09-09 22:11:54 +00:00
)
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)"
2026-09-09 22:11:54 +00:00
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.")
2026-09-09 22:11:54 +00:00
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")
2026-09-06 18:56:51 +00:00
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.")
2026-09-06 18:56:51 +00:00
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')}")
2026-09-06 21:25:35 +00:00
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)
2026-09-09 22:11:54 +00:00
wizard = board.get("wizard")
wizard_pos = (wizard["x"], wizard["y"]) if wizard else None
2026-09-06 21:25:35 +00:00
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("#")
2026-09-09 22:11:54 +00:00
elif wizard_pos and (x, y) == wizard_pos:
row_chars.append("W")
2026-09-06 21:25:35 +00:00
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
2026-09-06 18:56:51 +00:00
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"],
2026-09-06 21:25:35 +00:00
"x": t["x"],
"y": t["y"],
2026-09-06 18:56:51 +00:00
"distance": t["distance"],
"strength": t["strength"],
2026-09-06 21:25:35 +00:00
"party": t.get("party_name") or ("solo" if not t.get("party_id") else t.get("party_id")),
2026-09-06 18:56:51 +00:00
"is_ally": t.get("is_ally", False),
}
2026-09-06 21:25:35 +00:00
for t in radar_res.get("targets", [])
2026-09-06 18:56:51 +00:00
]
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
}
2026-09-06 21:25:35 +00:00
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,
2026-09-10 00:46:30 +00:00
W = Gary the Wizard NPC, M = mountain, F = forest, V = valley, # = out of bounds, . = open ground.
2026-09-06 21:25:35 +00:00
{chr(10).join(local_map)}
"""
2026-09-06 18:56:51 +00:00
prompt = f"""{GAME_RULES_SUMMARY}
2026-09-06 21:25:35 +00:00
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']}).
2026-09-06 18:56:51 +00:00
Server's radar suggestion: recommended_direction={radar_res.get('recommended_direction')},
recommended_action={radar_res.get('recommended_action')}, goal={radar_res.get('bot_goal')}.
2026-09-06 21:25:35 +00:00
{map_section}
All known bots/parties on the board (sorted nearest first): {json.dumps(targets_summary)}
2026-09-06 18:56:51 +00:00
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
2026-09-06 21:25:35 +00:00
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.
2026-09-06 18:56:51 +00:00
Respond ONLY with JSON: {{"direction": "<one of {available}>", "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)
2026-09-06 18:56:51 +00:00
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.")
2026-09-06 18:56:51 +00:00
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)))
2026-09-09 22:11:54 +00:00
env_health = int(os.environ.get("BOT_HEALTH", str(DEFAULT_BOT_HEALTH)))
2026-09-09 20:41:06 +00:00
env_piece_type = os.environ.get("BOT_PIECE_TYPE")
2026-09-06 18:56:51 +00:00
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)")
2026-09-09 22:11:54 +00:00
parser.add_argument("-H", "--health", dest="health", type=int, default=env_health,
help="Starting health points (default 10) (env: BOT_HEALTH)")
2026-09-09 20:41:06 +00:00
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)")
2026-09-06 18:56:51 +00:00
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,
2026-09-09 22:11:54 +00:00
health=args.health,
2026-09-06 18:56:51 +00:00
server_url=args.server_url,
ollama_url=args.ollama_url,
ollama_model=args.ollama_model,
2026-09-09 20:41:06 +00:00
piece_type=args.piece_type,
2026-09-06 18:56:51 +00:00
)
agent.run()
if __name__ == "__main__":
main()