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.
- Explicitly engages in 3-bout D20 battles when adjacent to an opposing party/refusing bot.
- 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 argparse
import requests
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:
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.color = color
self.strength = strength
self.base_url = normalize_url(server_url)
self.bot_id: Optional[str] = None
self.party_id: Optional[str] = None
self.is_leader: bool = False
@ -25,7 +47,7 @@ class SmartBotAgent:
def register(self):
"""Register the bot avatar on the 64x64 grid or reconnect if already present."""
try:
players = requests.get(f"{BASE_URL}/players").json()
players = requests.get(f"{self.base_url}/players").json()
for p in players:
if p.get("name") == self.name:
self.bot_id = p["id"]
@ -35,11 +57,11 @@ class SmartBotAgent:
pass
res = requests.post(
f"{BASE_URL}/players",
f"{self.base_url}/players",
json={"name": self.name, "color": self.color, "strength": self.strength},
)
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:
if p.get("name") == self.name:
self.bot_id = p["id"]
@ -53,7 +75,7 @@ class SmartBotAgent:
def refresh_status(self):
"""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:
data = res.json()
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'} ---")
# 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", [])
nearest = radar_res.get("nearest_target")
@ -113,9 +135,9 @@ class SmartBotAgent:
# Leadership Rule: Desires equal or stronger leader. Refuses weaker leader!
# Fetch target's party leader
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:
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)
if self.strength <= target_leader_str:
@ -130,7 +152,7 @@ class SmartBotAgent:
else:
if not self.is_leader:
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
if not target_party:
@ -150,7 +172,7 @@ class SmartBotAgent:
"""Form a party using the REST API."""
try:
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}"},
)
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']}")
else:
# 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:
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):
"""Explicitly call the 3-Bout D20 Battle endpoint."""
print(f"🎲 [BATTLE INITIATED] Clashing with opponent {opponent_id}...")
res = requests.post(
f"{BASE_URL}/battles/fight",
f"{self.base_url}/battles/fight",
json={"challenger_id": self.bot_id, "defender_id": opponent_id},
)
if res.status_code == 200:
@ -197,31 +219,31 @@ class SmartBotAgent:
def _step_or_attack(self, target: Dict[str, Any]):
"""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", {})
chosen = self._get_best_move_towards(target["x"], target["y"], moves)
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"):
print(f"⚔️ Move triggered battle! Winner: {res['battle_result']['winner_party_name']}")
elif res.get("party_formed_triggered"):
print(f"🤝 Move resulted in party alliance!")
else:
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]):
"""Move towards the nearest target routing around obstacles."""
rec_dir = radar_res.get("recommended_direction")
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", {})
available = [d for d, chk in moves.items() if chk.get("available")]
if not available:
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
# 1. Prefer radar's obstacle-aware BFS pathfinder direction
@ -235,7 +257,7 @@ class SmartBotAgent:
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"{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"):
print(f"⚔️ Encounter battle! Winner: {res['battle_result']['winner_party_name']}")
@ -247,7 +269,7 @@ class SmartBotAgent:
self.register()
try:
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):
print("⏳ [LOBBY] Waiting for game to start via 'Start Game' in UI...", end="\r", flush=True)
time.sleep(0.5)
@ -259,7 +281,7 @@ class SmartBotAgent:
self.decide_and_act()
# 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"):
print(f"\n🎉 [GAME CONCLUDED] All bots united under '{conc['winning_party_name']}'!")
break
@ -268,9 +290,56 @@ class SmartBotAgent:
except KeyboardInterrupt:
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__":
agent = SmartBotAgent(name="ExternalCyberBot", color="#10b981", strength=4)
agent.run()
main()