AI_Diplomacy/bot_client/multi_bot_launcher.py
Tyler Marques c09d5bfe7e
WIP: Exposing errors that cause bot to fail
Working to remove all the lower level try except statements that catch
barely, and don't offer any information to top level about what went
wrong. Better to throw exceptions and let the consumers catch if needed.
2025-07-29 16:00:40 -07:00

474 lines
16 KiB
Python

"""
Multi-Bot Launcher
A launcher script that starts multiple bot players for a full Diplomacy game.
This script can create a game and launch bots for all powers, or join bots
to an existing game.
"""
import argparse
import asyncio
from loguru import logger
import subprocess
import sys
import time
import select
import os
from typing import List, Dict, Optional
# Add parent directory to path for ai_diplomacy imports (runtime only)
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from websocket_diplomacy_client import connect_to_diplomacy_server
from diplomacy.engine.game import Game
class MultiBotLauncher:
"""
Launcher for multiple bot players.
Can either:
1. Create a new game and launch bots for all powers
2. Launch bots to join an existing game
"""
def __init__(
self,
hostname: str = "localhost",
port: int = 8432,
base_username: str = "bot",
password: str = "password",
):
self.game: Game
self.hostname = hostname
self.port = port
self.base_username = base_username
self.password = password
self.bot_processes: List[subprocess.Popen] = []
self.process_to_power: Dict[subprocess.Popen, str] = {}
self.game_id: Optional[str] = None
# Default power to model mapping
self.default_models = {
"AUSTRIA": "gemini-2.5-flash-lite-preview-06-17",
"ENGLAND": "gemini-2.5-flash-lite-preview-06-17",
"FRANCE": "gemini-2.5-flash-lite-preview-06-17",
"GERMANY": "gemini-2.5-flash-lite-preview-06-17",
"ITALY": "gemini-2.5-flash-lite-preview-06-17",
"RUSSIA": "gemini-2.5-flash-lite-preview-06-17",
"TURKEY": "gemini-2.5-flash-lite-preview-06-17",
}
async def create_game(self, creator_power: str = "FRANCE") -> str:
"""
Create a new game and return the game ID.
Args:
creator_power: Which power should create the game
Returns:
Game ID of the created game
"""
logger.info("Creating new game...")
# Connect as the game creator
creator_username = f"{self.base_username}_{creator_power.lower()}"
client = await connect_to_diplomacy_server(
hostname=self.hostname,
port=self.port,
username=creator_username,
password=self.password,
)
# Create the game
self.game = await client.create_game(
map_name="standard",
rules=["IGNORE_ERRORS", "POWER_CHOICE"], # Allow messages and power choice
power_name=creator_power,
n_controls=7, # Full 7-player game
deadline=None, # No time pressure
)
game_id = client.game_id
logger.info(f"Created game {game_id}")
# Leave the game so the bot can join properly
await client.game.leave()
await client.close()
assert game_id is not None, "game_id cannot be None, failed to create new game."
return game_id
def launch_bot(
self,
power: str,
model: str,
game_id: str,
log_level: str = "INFO",
negotiation_rounds: int = 3,
) -> subprocess.Popen:
"""
Launch a single bot process.
Args:
power: Power name (e.g., "FRANCE")
model: AI model to use
game_id: Game ID to join
log_level: Logging level
Returns:
subprocess.Popen object for the bot process
"""
username = f"{self.base_username}_{power.lower()}"
cmd = [
sys.executable,
"single_bot_player.py",
"--hostname",
self.hostname,
"--port",
str(self.port),
"--username",
username,
"--password",
self.password,
"--power",
power,
"--model",
model,
"--game-id",
game_id,
"--log-level",
log_level,
"--negotiation-rounds",
str(negotiation_rounds),
]
logger.info(f"Launching bot for {power} with model {model}")
logger.debug(f"Command: {' '.join(cmd)}")
# Launch bot in a new process
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
bufsize=1, # Line buffered
)
return process
async def launch_all_bots(
self,
game_id: str,
models: Optional[Dict[str, str]] = None,
powers: Optional[List[str]] = None,
log_level: str = "INFO",
stagger_delay: float = 0.5,
negotiation_rounds: int = 3,
):
"""
Launch bots for all specified powers.
Args:
game_id: Game ID to join
models: Mapping of power to model name (uses defaults if None)
powers: List of powers to launch bots for (all 7 if None)
log_level: Logging level for bots
stagger_delay: Delay between launching bots (seconds)
"""
if models is None:
models = self.default_models.copy()
if powers is None:
powers = list(self.default_models.keys())
logger.info(f"Launching bots for {len(powers)} powers...")
for i, power in enumerate(powers):
model = models.get(power, "gpt-3.5-turbo")
try:
process = self.launch_bot(power, model, game_id, log_level, negotiation_rounds)
self.bot_processes.append(process)
self.process_to_power[process] = power
logger.info(f"Launched bot {i + 1}/{len(powers)}: {power} (PID: {process.pid})")
# Stagger the launches to avoid overwhelming the server
if i < len(powers) - 1: # Don't delay after the last bot
await asyncio.sleep(stagger_delay)
except Exception as e:
logger.error(f"Failed to launch bot for {power}: {e}")
logger.info(f"All {len(self.bot_processes)} bots launched successfully")
def monitor_bots(self, check_interval: float = 1.0):
"""
Monitor bot processes and log their output.
Args:
check_interval: How often to check bot status (seconds)
"""
logger.info("Monitoring bot processes...")
try:
while self.bot_processes:
active_processes = []
# Collect all stdout file descriptors from active processes
stdout_fds = []
fd_to_process = {}
for process in self.bot_processes:
if process.poll() is None: # Still running
active_processes.append(process)
stdout_fd = process.stdout.fileno()
stdout_fds.append(stdout_fd)
fd_to_process[stdout_fd] = process
else:
# Process has ended
return_code = process.returncode
power = self.process_to_power.get(process, "UNKNOWN")
logger.info(f"{power} bot process {process.pid} ended with code {return_code}")
# Read any remaining output
remaining_output = process.stdout.read()
if remaining_output:
print(f"{power}_{process.pid} final output: {remaining_output}")
# Clean up the power mapping
self.process_to_power.pop(process, None)
self.bot_processes = active_processes
if not self.bot_processes:
logger.info("All bots have finished")
break
# Use select to check which processes have output ready (Unix only)
if stdout_fds and hasattr(select, "select"):
try:
ready_fds, _, _ = select.select(stdout_fds, [], [], 0.1) # 100ms timeout
for fd in ready_fds:
process = fd_to_process[fd]
power = self.process_to_power.get(process, "UNKNOWN")
# Read available lines (but limit to prevent monopolizing)
lines_read = 0
max_lines_per_process = 10
while lines_read < max_lines_per_process:
try:
line = process.stdout.readline()
if not line:
break
print(f"{power}_{process.pid}: {line.strip()}")
lines_read += 1
except:
break
except (OSError, ValueError):
# Fallback if select fails
self._fallback_read_output(active_processes)
else:
# Windows fallback or if select is not available
self._fallback_read_output(active_processes)
logger.debug(f"{len(self.bot_processes)} bots still running")
time.sleep(check_interval)
except KeyboardInterrupt:
logger.info("Received interrupt signal, stopping bots...")
self.stop_all_bots()
def _fallback_read_output(self, active_processes):
"""Fallback method for reading output when select is not available."""
for process in active_processes:
power = self.process_to_power.get(process, "UNKNOWN")
# Read limited lines per process to prevent monopolizing
lines_read = 0
max_lines_per_process = 3 # More conservative for fallback
while lines_read < max_lines_per_process:
try:
line = process.stdout.readline()
if not line:
break
print(f"{power}_{process.pid}: {line.strip()}")
lines_read += 1
except:
break
def stop_all_bots(self):
"""Stop all bot processes."""
logger.info("Stopping all bot processes...")
for process in self.bot_processes:
if process.poll() is None: # Still running
power = self.process_to_power.get(process, "UNKNOWN")
logger.info(f"Terminating {power} bot process {process.pid}")
process.terminate()
# Wait a bit for graceful shutdown
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
logger.warning(f"Force killing {power} bot process {process.pid}")
process.kill()
self.bot_processes.clear()
self.process_to_power.clear()
logger.info("All bots stopped")
async def run_full_game(
self,
models: Optional[Dict[str, str]] = None,
log_level: str = "INFO",
creator_power: str = "FRANCE",
negotiation_rounds: int = 3,
):
"""
Create a game and launch all bots for a complete game.
Args:
models: Power to model mapping
log_level: Logging level for bots
creator_power: Which power should create the game
"""
try:
# Create the game
game_id = await self.create_game(creator_power)
self.game_id = game_id
# Wait a moment for the server to be ready
await asyncio.sleep(2)
# Launch all bots
await self.launch_all_bots(
game_id,
models,
log_level=log_level,
negotiation_rounds=negotiation_rounds,
)
# Monitor the bots
self.monitor_bots()
except Exception as e:
logger.error(f"Error running full game: {e}", exc_info=True)
finally:
self.stop_all_bots()
async def join_existing_game(
self,
game_id: str,
powers: List[str],
models: Optional[Dict[str, str]] = None,
log_level: str = "INFO",
negotiation_rounds: int = 3,
):
"""
Launch bots to join an existing game.
Args:
game_id: Game ID to join
powers: List of powers to launch bots for
models: Power to model mapping
log_level: Logging level for bots
"""
try:
self.game_id = game_id
# Launch bots for specified powers
await self.launch_all_bots(
game_id,
models,
powers,
log_level,
negotiation_rounds=negotiation_rounds,
)
# Monitor the bots
self.monitor_bots()
except Exception as e:
logger.error(f"Error joining existing game: {e}", exc_info=True)
finally:
self.stop_all_bots()
def parse_arguments():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(description="Launch multiple bot players")
parser.add_argument("--hostname", default="localhost", help="Server hostname")
parser.add_argument("--port", type=int, default=8432, help="Server port")
parser.add_argument("--username-base", default="bot", help="Base username for bots")
parser.add_argument("--password", default="password", help="Password for all bots")
parser.add_argument("--game-id", help="Game ID to join (creates new if not specified)")
parser.add_argument("--powers", nargs="+", help="Powers to launch bots for (default: all)")
parser.add_argument("--models", help="Comma-separated list of models in power order")
parser.add_argument("--log-level", default="INFO", help="Logging level")
parser.add_argument("--creator-power", default="FRANCE", help="Power that creates the game")
parser.add_argument(
"--negotiation-rounds",
type=int,
default=3,
help="Number of negotiation rounds per movement phase (default: 3)",
)
return parser.parse_args()
async def main():
"""Main entry point."""
args = parse_arguments()
launcher = MultiBotLauncher(
hostname=args.hostname,
port=args.port,
base_username=args.username_base,
password=args.password,
)
# Parse models if provided
models = None
if args.models:
model_list = [m.strip() for m in args.models.split(",")]
powers = args.powers or list(launcher.default_models.keys())
if len(model_list) != len(powers):
logger.error(f"Number of models ({len(model_list)}) must match number of powers ({len(powers)})")
return
models = dict(zip(powers, model_list))
try:
if args.game_id:
# Join existing game
powers = args.powers or list(launcher.default_models.keys())
await launcher.join_existing_game(
game_id=args.game_id,
powers=powers,
models=models,
log_level=args.log_level,
negotiation_rounds=args.negotiation_rounds,
)
else:
# Create new game and launch all bots
await launcher.run_full_game(
models=models,
log_level=args.log_level,
creator_power=args.creator_power,
negotiation_rounds=args.negotiation_rounds,
)
except KeyboardInterrupt:
logger.info("Interrupted by user")
except Exception as e:
logger.error(f"Error in launcher: {e}", exc_info=True)
if __name__ == "__main__":
asyncio.run(main())