feat(botagent): support CLI flags and environment variables for URL, name, color, and strength

This commit is contained in:
Isaac Johnson 2026-09-06 08:32:14 -05:00
parent e9e95c409b
commit b70789c306
1 changed files with 94 additions and 25 deletions

View File

@ -4,20 +4,42 @@
- Decides whether to negotiate party formation or refuse & fight based on strength. - Decides whether to negotiate party formation or refuse & fight based on strength.
- Explicitly engages in 3-bout D20 battles when adjacent to an opposing party/refusing bot. - Explicitly engages in 3-bout D20 battles when adjacent to an opposing party/refusing bot.
- Parses battle results, bout rolls, scores, and absorbed members. - Parses battle results, bout rolls, scores, and absorbed members.
- Configurable via CLI arguments or environment variables (URL, name, color, strength).
""" """
import os
import sys
import time import time
import argparse
import requests import requests
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
BASE_URL = "http://localhost:8000/api" DEFAULT_SERVER_URL = "http://localhost:8000/api"
DEFAULT_BOT_NAME = "ExternalCyberBot"
DEFAULT_BOT_COLOR = "#10b981"
DEFAULT_BOT_STRENGTH = 4
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
class SmartBotAgent: class SmartBotAgent:
def __init__(self, name: str = "ExternalCyberBot", color: str = "#10b981", strength: int = 4): def __init__(
self,
name: str = DEFAULT_BOT_NAME,
color: str = DEFAULT_BOT_COLOR,
strength: int = DEFAULT_BOT_STRENGTH,
server_url: str = DEFAULT_SERVER_URL,
):
self.name = name self.name = name
self.color = color self.color = color
self.strength = strength self.strength = strength
self.base_url = normalize_url(server_url)
self.bot_id: Optional[str] = None self.bot_id: Optional[str] = None
self.party_id: Optional[str] = None self.party_id: Optional[str] = None
self.is_leader: bool = False self.is_leader: bool = False
@ -25,7 +47,7 @@ class SmartBotAgent:
def register(self): def register(self):
"""Register the bot avatar on the 64x64 grid or reconnect if already present.""" """Register the bot avatar on the 64x64 grid or reconnect if already present."""
try: try:
players = requests.get(f"{BASE_URL}/players").json() players = requests.get(f"{self.base_url}/players").json()
for p in players: for p in players:
if p.get("name") == self.name: if p.get("name") == self.name:
self.bot_id = p["id"] self.bot_id = p["id"]
@ -35,11 +57,11 @@ class SmartBotAgent:
pass pass
res = requests.post( res = requests.post(
f"{BASE_URL}/players", f"{self.base_url}/players",
json={"name": self.name, "color": self.color, "strength": self.strength}, json={"name": self.name, "color": self.color, "strength": self.strength},
) )
if res.status_code == 400 and "already registered" in res.text: if res.status_code == 400 and "already registered" in res.text:
players = requests.get(f"{BASE_URL}/players").json() players = requests.get(f"{self.base_url}/players").json()
for p in players: for p in players:
if p.get("name") == self.name: if p.get("name") == self.name:
self.bot_id = p["id"] self.bot_id = p["id"]
@ -53,7 +75,7 @@ class SmartBotAgent:
def refresh_status(self): def refresh_status(self):
"""Update bot state (party membership, leader status, score).""" """Update bot state (party membership, leader status, score)."""
res = requests.get(f"{BASE_URL}/players/{self.bot_id}") res = requests.get(f"{self.base_url}/players/{self.bot_id}")
if res.status_code == 200: if res.status_code == 200:
data = res.json() data = res.json()
self.party_id = data.get("party_id") self.party_id = data.get("party_id")
@ -70,7 +92,7 @@ class SmartBotAgent:
print(f"\n🎮 --- Turn for {self.name} | Score: {my_info['score']} | Party: {self.party_id or 'Solo'} ---") print(f"\n🎮 --- Turn for {self.name} | Score: {my_info['score']} | Party: {self.party_id or 'Solo'} ---")
# 1. Consult Radar Sensor # 1. Consult Radar Sensor
radar_res = requests.get(f"{BASE_URL}/players/{self.bot_id}/radar").json() radar_res = requests.get(f"{self.base_url}/players/{self.bot_id}/radar").json()
targets = radar_res.get("targets", []) targets = radar_res.get("targets", [])
nearest = radar_res.get("nearest_target") nearest = radar_res.get("nearest_target")
@ -113,9 +135,9 @@ class SmartBotAgent:
# Leadership Rule: Desires equal or stronger leader. Refuses weaker leader! # Leadership Rule: Desires equal or stronger leader. Refuses weaker leader!
# Fetch target's party leader # Fetch target's party leader
target_leader_str = target_str # default fallback target_leader_str = target_str # default fallback
party_info = requests.get(f"{BASE_URL}/parties/{target_party}").json() party_info = requests.get(f"{self.base_url}/parties/{target_party}").json()
if party_info: if party_info:
leader_player = requests.get(f"{BASE_URL}/players/{party_info['leader_id']}").json() leader_player = requests.get(f"{self.base_url}/players/{party_info['leader_id']}").json()
target_leader_str = leader_player.get("strength", 1) target_leader_str = leader_player.get("strength", 1)
if self.strength <= target_leader_str: if self.strength <= target_leader_str:
@ -130,7 +152,7 @@ class SmartBotAgent:
else: else:
if not self.is_leader: if not self.is_leader:
print(f"🛡️ [PARTY MEMBER] Under command of party leader. Awaiting leader movement.") print(f"🛡️ [PARTY MEMBER] Under command of party leader. Awaiting leader movement.")
requests.post(f"{BASE_URL}/players/{self.bot_id}/pass") requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
return return
if not target_party: if not target_party:
@ -150,7 +172,7 @@ class SmartBotAgent:
"""Form a party using the REST API.""" """Form a party using the REST API."""
try: try:
res = requests.post( res = requests.post(
f"{BASE_URL}/parties", f"{self.base_url}/parties",
json={"member_ids": member_ids, "leader_id": leader_id, "name": f"Squad_{self.name}"}, json={"member_ids": member_ids, "leader_id": leader_id, "name": f"Squad_{self.name}"},
) )
if res.status_code == 201: if res.status_code == 201:
@ -158,16 +180,16 @@ class SmartBotAgent:
print(f"✅ [PARTY FORMED] Squad '{party['name']}' established! Leader: {party['leader_name']} | Str: {party['total_strength']}") print(f"✅ [PARTY FORMED] Squad '{party['name']}' established! Leader: {party['leader_name']} | Str: {party['total_strength']}")
else: else:
# Fallback: pass turn if party creation rejected # Fallback: pass turn if party creation rejected
requests.post(f"{BASE_URL}/players/{self.bot_id}/pass") requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
except Exception as e: except Exception as e:
print(f"Party formation error: {e}") print(f"Party formation error: {e}")
requests.post(f"{BASE_URL}/players/{self.bot_id}/pass") requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
def _initiate_battle(self, opponent_id: str): def _initiate_battle(self, opponent_id: str):
"""Explicitly call the 3-Bout D20 Battle endpoint.""" """Explicitly call the 3-Bout D20 Battle endpoint."""
print(f"🎲 [BATTLE INITIATED] Clashing with opponent {opponent_id}...") print(f"🎲 [BATTLE INITIATED] Clashing with opponent {opponent_id}...")
res = requests.post( res = requests.post(
f"{BASE_URL}/battles/fight", f"{self.base_url}/battles/fight",
json={"challenger_id": self.bot_id, "defender_id": opponent_id}, json={"challenger_id": self.bot_id, "defender_id": opponent_id},
) )
if res.status_code == 200: if res.status_code == 200:
@ -197,31 +219,31 @@ class SmartBotAgent:
def _step_or_attack(self, target: Dict[str, Any]): def _step_or_attack(self, target: Dict[str, Any]):
"""Move adjacent/towards the target while avoiding obstacles.""" """Move adjacent/towards the target while avoiding obstacles."""
moves_res = requests.get(f"{BASE_URL}/players/{self.bot_id}/available-moves").json() moves_res = requests.get(f"{self.base_url}/players/{self.bot_id}/available-moves").json()
moves = moves_res.get("moves", {}) moves = moves_res.get("moves", {})
chosen = self._get_best_move_towards(target["x"], target["y"], moves) chosen = self._get_best_move_towards(target["x"], target["y"], moves)
if chosen: if chosen:
res = requests.post(f"{BASE_URL}/players/{self.bot_id}/move", json={"direction": chosen}).json() res = requests.post(f"{self.base_url}/players/{self.bot_id}/move", json={"direction": chosen}).json()
if res.get("battle_triggered"): if res.get("battle_triggered"):
print(f"⚔️ Move triggered battle! Winner: {res['battle_result']['winner_party_name']}") print(f"⚔️ Move triggered battle! Winner: {res['battle_result']['winner_party_name']}")
elif res.get("party_formed_triggered"): elif res.get("party_formed_triggered"):
print(f"🤝 Move resulted in party alliance!") print(f"🤝 Move resulted in party alliance!")
else: else:
print("⚠️ No passable moves adjacent to target (terrain/border constraint). Passing turn.") print("⚠️ No passable moves adjacent to target (terrain/border constraint). Passing turn.")
requests.post(f"{BASE_URL}/players/{self.bot_id}/pass") requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
def _navigate_towards_goal(self, radar_res: Dict[str, Any]): def _navigate_towards_goal(self, radar_res: Dict[str, Any]):
"""Move towards the nearest target routing around obstacles.""" """Move towards the nearest target routing around obstacles."""
rec_dir = radar_res.get("recommended_direction") rec_dir = radar_res.get("recommended_direction")
nearest = radar_res.get("nearest_target") nearest = radar_res.get("nearest_target")
moves_res = requests.get(f"{BASE_URL}/players/{self.bot_id}/available-moves").json() moves_res = requests.get(f"{self.base_url}/players/{self.bot_id}/available-moves").json()
moves = moves_res.get("moves", {}) moves = moves_res.get("moves", {})
available = [d for d, chk in moves.items() if chk.get("available")] available = [d for d, chk in moves.items() if chk.get("available")]
if not available: if not available:
print("🚫 All adjacent paths blocked by borders or obstacle terrain (mountains/forests). Passing turn.") print("🚫 All adjacent paths blocked by borders or obstacle terrain (mountains/forests). Passing turn.")
requests.post(f"{BASE_URL}/players/{self.bot_id}/pass") requests.post(f"{self.base_url}/players/{self.bot_id}/pass")
return return
# 1. Prefer radar's obstacle-aware BFS pathfinder direction # 1. Prefer radar's obstacle-aware BFS pathfinder direction
@ -235,7 +257,7 @@ class SmartBotAgent:
chosen_dir = available[0] chosen_dir = available[0]
print(f"🧭 Moving {chosen_dir} (Goal: {radar_res.get('bot_goal')}, Action: {radar_res.get('recommended_action')})") print(f"🧭 Moving {chosen_dir} (Goal: {radar_res.get('bot_goal')}, Action: {radar_res.get('recommended_action')})")
res = requests.post(f"{BASE_URL}/players/{self.bot_id}/move", json={"direction": chosen_dir}).json() res = requests.post(f"{self.base_url}/players/{self.bot_id}/move", json={"direction": chosen_dir}).json()
if res.get("battle_triggered"): if res.get("battle_triggered"):
print(f"⚔️ Encounter battle! Winner: {res['battle_result']['winner_party_name']}") print(f"⚔️ Encounter battle! Winner: {res['battle_result']['winner_party_name']}")
@ -247,7 +269,7 @@ class SmartBotAgent:
self.register() self.register()
try: try:
while True: while True:
turn_info = requests.get(f"{BASE_URL}/turn").json() turn_info = requests.get(f"{self.base_url}/turn").json()
if not turn_info.get("game_started", False): if not turn_info.get("game_started", False):
print("⏳ [LOBBY] Waiting for game to start via 'Start Game' in UI...", end="\r", flush=True) print("⏳ [LOBBY] Waiting for game to start via 'Start Game' in UI...", end="\r", flush=True)
time.sleep(0.5) time.sleep(0.5)
@ -259,7 +281,7 @@ class SmartBotAgent:
self.decide_and_act() self.decide_and_act()
# Check for game conclusion # Check for game conclusion
conc = requests.get(f"{BASE_URL}/game/conclusion").json() conc = requests.get(f"{self.base_url}/game/conclusion").json()
if conc.get("concluded"): if conc.get("concluded"):
print(f"\n🎉 [GAME CONCLUDED] All bots united under '{conc['winning_party_name']}'!") print(f"\n🎉 [GAME CONCLUDED] All bots united under '{conc['winning_party_name']}'!")
break break
@ -268,9 +290,56 @@ class SmartBotAgent:
except KeyboardInterrupt: except KeyboardInterrupt:
print(f"\nDisconnecting {self.name}...") print(f"\nDisconnecting {self.name}...")
requests.delete(f"{BASE_URL}/players/{self.bot_id}") requests.delete(f"{self.base_url}/players/{self.bot_id}")
def main():
# Read defaults from environment variables if present
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)))
parser = argparse.ArgumentParser(
description="Autonomous External Bot Agent for botWebWars",
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, e.g. #10b981 (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)",
)
args = parser.parse_args()
agent = SmartBotAgent(
name=args.name,
color=args.color,
strength=args.strength,
server_url=args.server_url,
)
agent.run()
if __name__ == "__main__": if __name__ == "__main__":
agent = SmartBotAgent(name="ExternalCyberBot", color="#10b981", strength=4) main()
agent.run()