mirror of
https://github.com/GoodStartLabs/AI_Diplomacy.git
synced 2026-04-19 12:58:09 +00:00
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.
474 lines
16 KiB
Python
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())
|