diff --git a/bot_client/main.py b/bot_client/main.py new file mode 100644 index 0000000..7d29126 --- /dev/null +++ b/bot_client/main.py @@ -0,0 +1,90 @@ +from single_bot_player import SingleBotPlayer +import asyncio +import argparse +from loguru import logger +from config import config +from utils import create_game + + +def parse_arguments(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser(description="Single bot player for Diplomacy") + + parser.add_argument("--hostname", default="localhost", help="Server hostname") + parser.add_argument("--port", type=int, default=8432, help="Server port") + parser.add_argument("--power", default="FRANCE", help="Power to control") + parser.add_argument("--model", default="gemini-2.5-flash-lite-preview-06-17", help="AI model to use") + parser.add_argument("--game-id", help="Game ID to join (creates new if not specified)") + parser.add_argument("--log-level", default="INFO", help="Logging level") + parser.add_argument("--fill-game", action="store_true", default=False, help="Launch one bot, or fill the game with bots. Default is one bot") + parser.add_argument( + "--negotiation-rounds", + type=int, + default=3, + help="Number of negotiation rounds per movement phase (default: 3)", + ) + parser.add_argument( + "--connection-timeout", + type=float, + default=30.0, + help="Timeout for network operations in seconds (default: 30.0)", + ) + parser.add_argument( + "--max-retries", + type=int, + default=3, + help="Maximum number of retries for failed operations (default: 3)", + ) + parser.add_argument( + "--retry-delay", + type=float, + default=2.0, + help="Base delay between retries in seconds (default: 2.0)", + ) + + return parser.parse_args() + + +async def main(): + """Main entry point with comprehensive error handling.""" + bots = {} + args = parse_arguments() + + if not args.game_id: + # No game id, lets create a new game. + args.game_id = await create_game() + + if args.fill_game: + for power_str, model_str in config.DEFAULT_POWER_MODELS_MAP: + bots[power_str] = SingleBotPlayer(power_name=power_str, model_name=model_str) + for power, bot in bots.items(): + try: + await bot.run() + finally: + await bot.cleanup() + + else: + bot = SingleBotPlayer( + hostname=args.hostname, + port=args.port, + power_name=args.power, + model_name=args.model, + game_id=args.game_id, + negotiation_rounds=args.negotiation_rounds, + connection_timeout=args.connection_timeout, + max_retries=args.max_retries, + retry_delay=args.retry_delay, + ) + + try: + await bot.run() + except KeyboardInterrupt: + logger.info("Received keyboard interrupt") + finally: + if bot: + # Ensure cleanup happens even if there was an error + await bot.cleanup() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bot_client/multi_bot_launcher.py b/bot_client/multi_bot_launcher.py deleted file mode 100644 index be47a3f..0000000 --- a/bot_client/multi_bot_launcher.py +++ /dev/null @@ -1,525 +0,0 @@ -""" -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}" - 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, - connection_timeout: float = 30.0, - max_retries: int = 3, - retry_delay: float = 2.0, - ) -> 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), - "--connection-timeout", - str(connection_timeout), - "--max-retries", - str(max_retries), - "--retry-delay", - str(retry_delay), - ] - - 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, - connection_timeout: float = 30.0, - max_retries: int = 3, - retry_delay: float = 2.0, - ): - """ - 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, connection_timeout, max_retries, retry_delay) - 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 Exception: - 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 Exception: - 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, - connection_timeout: float = 30.0, - max_retries: int = 3, - retry_delay: float = 2.0, - ): - """ - 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, - connection_timeout=connection_timeout, - max_retries=max_retries, - retry_delay=retry_delay, - ) - - # 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, - connection_timeout: float = 30.0, - max_retries: int = 3, - retry_delay: float = 2.0, - ): - """ - 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, - connection_timeout=connection_timeout, - max_retries=max_retries, - retry_delay=retry_delay, - ) - - # 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)", - ) - parser.add_argument( - "--connection-timeout", - type=float, - default=30.0, - help="Timeout for network operations in seconds (default: 30.0)", - ) - parser.add_argument( - "--max-retries", - type=int, - default=3, - help="Maximum number of retries for failed operations (default: 3)", - ) - parser.add_argument( - "--retry-delay", - type=float, - default=2.0, - help="Base delay between retries in seconds (default: 2.0)", - ) - - return parser.parse_args() - - -async def main(): - """Main entry point.""" - - # FIXME: Arg parse appears to not like game ids with hypens in the name. e.g. - # uv run python multi_bot_launcher.py --game-id "-1D0i-fobmvprIh1" results in an error - 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, - connection_timeout=args.connection_timeout, - max_retries=args.max_retries, - retry_delay=args.retry_delay, - ) - 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, - connection_timeout=args.connection_timeout, - max_retries=args.max_retries, - retry_delay=args.retry_delay, - ) - - 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()) diff --git a/bot_client/config.py b/bot_client/src/config.py similarity index 70% rename from bot_client/config.py rename to bot_client/src/config.py index 957173e..760d98a 100644 --- a/bot_client/config.py +++ b/bot_client/src/config.py @@ -1,4 +1,5 @@ import datetime +from typing import Dict, List from pydantic_settings import BaseSettings from pathlib import Path import warnings @@ -12,10 +13,22 @@ class Configuration(BaseSettings): ANTHROPIC_API_KEY: str | None = None GEMINI_API_KEY: str | None = None OPENROUTER_API_KEY: str | None = None + DEFAULT_GAME_RULES: List[str] = ["POWER_CHOICE"] # Allow messages and power choice - def __init__(self, power_name, **kwargs): + # Default power to model mapping + DEFAULT_POWER_MODELS_MAP: Dict[str, str] = { + "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", + } + + def __init__(self, **kwargs): super().__init__(**kwargs) - self.log_file_path = Path(f"./logs/{datetime.datetime.now().strftime('%d-%m-%y_%H:%M')}/{power_name}.txt") + self.log_file_path = Path(f"./logs/{datetime.datetime.now().strftime('%d-%m-%y_%H:%M')}.txt") # Make the path absolute, gets rid of weirdness of calling this in different places self.log_file_path = self.log_file_path.resolve() self.log_file_path.parent.mkdir(parents=True, exist_ok=True) @@ -46,3 +59,6 @@ class Configuration(BaseSettings): raise ValueError(f"API key '{name}' is not set or is empty. Please configure it before use.") return value + + +config = Configuration() diff --git a/bot_client/lm_game_websocket.py b/bot_client/src/lm_game_websocket.py similarity index 100% rename from bot_client/lm_game_websocket.py rename to bot_client/src/lm_game_websocket.py diff --git a/bot_client/models.py b/bot_client/src/models.py similarity index 100% rename from bot_client/models.py rename to bot_client/src/models.py diff --git a/bot_client/single_bot_player.py b/bot_client/src/single_bot_player.py similarity index 68% rename from bot_client/single_bot_player.py rename to bot_client/src/single_bot_player.py index af30688..5f3e76e 100644 --- a/bot_client/single_bot_player.py +++ b/bot_client/src/single_bot_player.py @@ -40,8 +40,6 @@ from websocket_negotiations import ( dotenv.load_dotenv() -# TODO: This, but better - class SingleBotPlayer: """ @@ -55,8 +53,6 @@ class SingleBotPlayer: def __init__( self, - username: str, - password: str, power_name: str, model_name: str, hostname: str = "localhost", @@ -67,19 +63,17 @@ class SingleBotPlayer: max_retries: int = 3, retry_delay: float = 2.0, ): - assert username is not None - assert password is not None assert power_name is not None assert model_name is not None self.hostname = hostname self.port = port - self.username = username - self.password = password + self.username = f"bot_{power_name}" + self.password = "password" self.power_name = power_name self.model_name = model_name self.game_id = game_id - self.config = Configuration(power_name=power_name) + self.config = Configuration() # Bot state self.client: WebSocketDiplomacyClient @@ -311,20 +305,14 @@ class SingleBotPlayer: async def _handle_game_processed_async(self): """Async handler for game processing.""" - try: - # Synchronize to get the results with retry logic - await self._retry_with_backoff(self.client.game.synchronize) + # Synchronize to get the results with retry logic + await self._retry_with_backoff(self.client.game.synchronize) - # Analyze the results - await self._analyze_phase_results() + # Analyze the results + await self._analyze_phase_results() - self.orders_submitted = False - self.waiting_for_orders = False - except Exception as e: - logger.error(f"Failed to handle game processing: {e}") - # Reset state even if synchronization failed - self.orders_submitted = False - self.waiting_for_orders = False + self.orders_submitted = False + self.waiting_for_orders = False def _on_message_received(self, game, notification): """Handle incoming diplomatic messages.""" @@ -431,74 +419,61 @@ class SingleBotPlayer: logger.debug("Orders already submitted for this phase") return - try: - current_phase = self.client.game.get_current_phase() - logger.info(f"Generating orders for {self.power_name} in phase {current_phase}...") + current_phase = self.client.game.get_current_phase() + logger.info(f"Generating orders for {self.power_name} in phase {current_phase}...") - # Get current board state - board_state = self.client.game.get_state() + # Get current board state + board_state = self.client.game.get_state() - # Get possible orders - possible_orders = gather_possible_orders(self.client.game, self.power_name) + # Get possible orders + possible_orders = gather_possible_orders(self.client.game, self.power_name) - logger.debug(f"Possible orders for {self.power_name}: {possible_orders}") + logger.debug(f"Possible orders for {self.power_name}: {possible_orders}") - if not possible_orders: - logger.info(f"No possible orders for {self.power_name}, submitting empty order set") - await self._retry_with_backoff(self.client.set_orders, self.power_name, []) - self.orders_submitted = True - return + if not possible_orders: + logger.info(f"No possible orders for {self.power_name}, submitting empty order set") + await self._retry_with_backoff(self.client.set_orders, self.power_name, []) + self.orders_submitted = True + return - # Generate orders using AI - orders = await get_valid_orders( - game=self.client.game, - client=self.agent.client, - board_state=board_state, - power_name=self.power_name, - possible_orders=possible_orders, - game_history=self.game_history, - model_error_stats=self.error_stats, - agent_goals=self.agent.goals, - agent_relationships=self.agent.relationships, - agent_private_diary_str=self.agent.format_private_diary_for_prompt(), - phase=self.client.game.get_current_phase(), + # Generate orders using AI + orders = await get_valid_orders( + game=self.client.game, + client=self.agent.client, + board_state=board_state, + power_name=self.power_name, + possible_orders=possible_orders, + game_history=self.game_history, + model_error_stats=self.error_stats, + agent_goals=self.agent.goals, + agent_relationships=self.agent.relationships, + agent_private_diary_str=self.agent.format_private_diary_for_prompt(), + phase=self.client.game.get_current_phase(), + ) + + # Submit orders with retry logic + if orders: + logger.info(f"Submitting orders: {orders}") + await self._retry_with_backoff(self.client.set_orders, self.power_name, orders) + + # Generate order diary entry (don't retry this if it fails) + await self.agent.generate_order_diary_entry( + self.client.game, + orders, + self.config.log_file_path, ) + else: + logger.info("No valid orders generated, submitting empty order set") + await self._retry_with_backoff(self.client.set_orders, self.power_name, []) - # Submit orders with retry logic - if orders: - logger.info(f"Submitting orders: {orders}") - await self._retry_with_backoff(self.client.set_orders, self.power_name, orders) + self.orders_submitted = True + self.waiting_for_orders = False + logger.info("Orders submitted successfully") - # Generate order diary entry (don't retry this if it fails) - try: - await self.agent.generate_order_diary_entry( - self.client.game, - orders, - self.config.log_file_path, - ) - except Exception as diary_error: - logger.warning(f"Failed to generate order diary entry: {diary_error}") - else: - logger.info("No valid orders generated, submitting empty order set") - await self._retry_with_backoff(self.client.set_orders, self.power_name, []) - - self.orders_submitted = True - self.waiting_for_orders = False - logger.info("Orders submitted successfully") - - # Call the no wait so we don't sit around for the turns to end. - # TODO: We probably don't want to call this here. - # We want to call it when negotiations end, - try: - self.client.game.no_wait() - except Exception as no_wait_error: - logger.warning(f"Failed to call no_wait: {no_wait_error}") - - except Exception as e: - logger.error(f"Failed to submit orders: {e}", exc_info=True) - # Mark as submitted to avoid infinite retry loops - self.orders_submitted = True - self.waiting_for_orders = False + # Call the no wait so we don't sit around for the turns to end. + # TODO: We probably don't want to call this here. + # We want to call it when negotiations end, + self.client.game.no_wait() async def _analyze_phase_results(self): """Analyze the results of the previous phase.""" @@ -563,119 +538,111 @@ class SingleBotPlayer: async def _consider_message_response(self, message: Message): """Consider whether to respond to a diplomatic message.""" - try: - # Only respond to messages directed at us specifically - if message.recipient != self.power_name: - return + # Only respond to messages directed at us specifically + if message.recipient != self.power_name: + return - # Don't respond to our own messages - if message.sender == self.power_name: - return + # Don't respond to our own messages + if message.sender == self.power_name: + return - logger.info(f"Considering response to message from {message.sender}: {message.message[:50]}...") + logger.info(f"Considering response to message from {message.sender}: {message.message[:50]}...") - # Enhanced heuristic: respond to direct questions, proposals, and strategic keywords - message_lower = message.message.lower() - strategic_keywords = [ - "alliance", - "deal", - "propose", - "agreement", - "support", - "attack", - "coordinate", - "move", - "order", - "help", - "work together", - "partner", - "enemy", - "threat", - "negotiate", - "discuss", - "plan", - "strategy", - "bounce", - "convoy", - "retreat", + # Enhanced heuristic: respond to direct questions, proposals, and strategic keywords + message_lower = message.message.lower() + strategic_keywords = [ + "alliance", + "deal", + "propose", + "agreement", + "support", + "attack", + "coordinate", + "move", + "order", + "help", + "work together", + "partner", + "enemy", + "threat", + "negotiate", + "discuss", + "plan", + "strategy", + "bounce", + "convoy", + "retreat", + ] + + should_respond = any( + [ + "?" in message.message, # Questions + any(word in message_lower for word in ["hello", "hi", "greetings"]), # Greetings + any(keyword in message_lower for keyword in strategic_keywords), # Strategic content + len(message.message.split()) > 15, # Longer messages suggest they want engagement + message.sender in self.priority_contacts, # Priority contacts ] + ) - should_respond = any( - [ - "?" in message.message, # Questions - any(word in message_lower for word in ["hello", "hi", "greetings"]), # Greetings - any(keyword in message_lower for keyword in strategic_keywords), # Strategic content - len(message.message.split()) > 15, # Longer messages suggest they want engagement - message.sender in self.priority_contacts, # Priority contacts - ] + if should_respond: + # Generate a contextual response using AI + # Get current game state for context + board_state = self.client.get_state() + possible_orders = gather_possible_orders(self.client.game, self.power_name) + + # Create a simple conversation context + active_powers = [p_name for p_name, p_obj in self.client.powers.items() if not p_obj.is_eliminated()] + + # Generate response using the agent's conversation capabilities + responses = await self.agent.client.get_conversation_reply( + game=self.client.game, + board_state=board_state, + power_name=self.power_name, + possible_orders=possible_orders, + game_history=self.game_history, + game_phase=self.client.get_current_short_phase(), + log_file_path=self.config.log_file_path, + active_powers=active_powers, + agent_goals=self.agent.goals, + agent_relationships=self.agent.relationships, + agent_private_diary_str=self.agent.format_private_diary_for_prompt(), ) - if should_respond: - # Generate a contextual response using AI - # Get current game state for context - board_state = self.client.get_state() - possible_orders = gather_possible_orders(self.client.game, self.power_name) + # Send the first response if any were generated + if responses and len(responses) > 0: + response_content = responses[0].get("content", "").strip() + if response_content: + await self._retry_with_backoff( + self.client.send_message, + sender=self.power_name, + recipient=message.sender, + message=response_content, + phase=self.client.get_current_short_phase(), + ) - # Create a simple conversation context - active_powers = [p_name for p_name, p_obj in self.client.powers.items() if not p_obj.is_eliminated()] + # Add to game history + self.game_history.add_message( + phase_name=self.client.get_current_short_phase(), + sender=self.power_name, + recipient=message.sender, + message_content=response_content, + ) - # Generate response using the agent's conversation capabilities - responses = await self.agent.client.get_conversation_reply( - game=self.client.game, - board_state=board_state, - power_name=self.power_name, - possible_orders=possible_orders, - game_history=self.game_history, - game_phase=self.client.get_current_short_phase(), - log_file_path=self.config.log_file_path, - active_powers=active_powers, - agent_goals=self.agent.goals, - agent_relationships=self.agent.relationships, - agent_private_diary_str=self.agent.format_private_diary_for_prompt(), - ) + # Track response patterns + self.response_counts[message.sender] = self.response_counts.get(message.sender, 0) + 1 - # Send the first response if any were generated - if responses and len(responses) > 0: - response_content = responses[0].get("content", "").strip() - if response_content: - try: - await self._retry_with_backoff( - self.client.send_message, - sender=self.power_name, - recipient=message.sender, - message=response_content, - phase=self.client.get_current_short_phase(), - ) - except Exception as send_error: - logger.warning(f"Failed to send message response: {send_error}") - return # Don't record the message if sending failed + # Add to agent's journal + self.agent.add_journal_entry( + f"Responded to {message.sender} in {self.client.get_current_short_phase()}: {response_content[:100]}..." + ) - # Add to game history - self.game_history.add_message( - phase_name=self.client.get_current_short_phase(), - sender=self.power_name, - recipient=message.sender, - message_content=response_content, - ) - - # Track response patterns - self.response_counts[message.sender] = self.response_counts.get(message.sender, 0) + 1 - - # Add to agent's journal - self.agent.add_journal_entry( - f"Responded to {message.sender} in {self.client.get_current_short_phase()}: {response_content[:100]}..." - ) - - logger.info(f"Sent AI response to {message.sender}: {response_content[:50]}...") - else: - logger.debug(f"AI generated empty response to {message.sender}") + logger.info(f"Sent AI response to {message.sender}: {response_content[:50]}...") else: - logger.debug(f"AI generated no responses to {message.sender}") + logger.debug(f"AI generated empty response to {message.sender}") else: - logger.debug(f"Decided not to respond to message from {message.sender}") - - except Exception as e: - logger.error(f"Error responding to message: {e}", exc_info=True) + logger.debug(f"AI generated no responses to {message.sender}") + else: + logger.debug(f"Decided not to respond to message from {message.sender}") def _update_priority_contacts(self) -> None: """Update the list of priority contacts based on messaging patterns.""" @@ -768,8 +735,6 @@ class SingleBotPlayer: logger.error(f"Game with id {self.game_id} does not exist on the server. Exiting...") except (asyncio.CancelledError, KeyboardInterrupt): logger.info("Bot cancelled or interrupted") - except Exception as e: - logger.error(f"Fatal error in bot: {e}", exc_info=True) finally: await self.cleanup() @@ -784,8 +749,6 @@ class SingleBotPlayer: logger.info("Cleanup completed successfully") except asyncio.TimeoutError: logger.warning(f"Cleanup timed out after {cleanup_timeout} seconds") - except Exception as e: - logger.error(f"Error during cleanup: {e}") async def _perform_cleanup(self): """Perform the actual cleanup operations.""" @@ -794,21 +757,15 @@ class SingleBotPlayer: # Game cleanup if hasattr(self, "client") and self.client and hasattr(self.client, "game") and self.client.game: logger.debug("Cleaning up game connection...") - try: - # Use asyncio.create_task to make game.leave() non-blocking - leave_task = asyncio.create_task(self._safe_game_leave()) - cleanup_tasks.append(leave_task) - except Exception as e: - logger.warning(f"Error creating game leave task: {e}") + # Use asyncio.create_task to make game.leave() non-blocking + leave_task = asyncio.create_task(self._safe_game_leave()) + cleanup_tasks.append(leave_task) # Client cleanup if hasattr(self, "client") and self.client: logger.debug("Cleaning up client connection...") - try: - close_task = asyncio.create_task(self._safe_client_close()) - cleanup_tasks.append(close_task) - except Exception as e: - logger.warning(f"Error creating client close task: {e}") + close_task = asyncio.create_task(self._safe_client_close()) + cleanup_tasks.append(close_task) # Wait for all cleanup tasks with individual timeouts if cleanup_tasks: @@ -824,25 +781,17 @@ class SingleBotPlayer: task.cancel() try: await task - except asyncio.CancelledError: - pass - except Exception as e: - logger.warning(f"Error cancelling cleanup task: {e}") + except asyncio.CancelledError as e: + logger.warning(f"Task was cancelled, rasied {e}", exc_info=True) async def _safe_game_leave(self): """Safely leave the game with timeout.""" try: # Some diplomacy client implementations have async leave, others are sync - if asyncio.iscoroutinefunction(self.client.game.leave): - await asyncio.wait_for(self.client.game.leave(), timeout=5.0) - else: - # Run synchronous leave in a thread to avoid blocking - await asyncio.get_event_loop().run_in_executor(None, self.client.game.leave) + await self.client.game.leave() logger.debug("Successfully left game") except asyncio.TimeoutError: logger.warning("Game leave operation timed out") - except Exception as e: - logger.warning(f"Error leaving game: {e}") async def _safe_client_close(self): """Safely close the client with timeout.""" @@ -851,86 +800,3 @@ class SingleBotPlayer: logger.debug("Successfully closed client") except asyncio.TimeoutError: logger.warning("Client close operation timed out") - except Exception as e: - logger.warning(f"Error closing client: {e}") - - -def parse_arguments(): - """Parse command line arguments.""" - parser = argparse.ArgumentParser(description="Single bot player for Diplomacy") - - parser.add_argument("--hostname", default="localhost", help="Server hostname") - parser.add_argument("--port", type=int, default=8432, help="Server port") - parser.add_argument("--username", help="Bot username") - parser.add_argument("--password", default="password", help="Bot password") - parser.add_argument("--power", default="FRANCE", help="Power to control") - parser.add_argument("--model", default="gpt-3.5-turbo", help="AI model to use") - parser.add_argument("--game-id", help="Game ID to join (creates new if not specified)") - parser.add_argument("--log-level", default="INFO", help="Logging level") - parser.add_argument( - "--negotiation-rounds", - type=int, - default=3, - help="Number of negotiation rounds per movement phase (default: 3)", - ) - parser.add_argument( - "--connection-timeout", - type=float, - default=30.0, - help="Timeout for network operations in seconds (default: 30.0)", - ) - parser.add_argument( - "--max-retries", - type=int, - default=3, - help="Maximum number of retries for failed operations (default: 3)", - ) - parser.add_argument( - "--retry-delay", - type=float, - default=2.0, - help="Base delay between retries in seconds (default: 2.0)", - ) - - return parser.parse_args() - - -async def main(): - """Main entry point with comprehensive error handling.""" - bot = None - try: - args = parse_arguments() - if not args.username: - args.username = f"bot_{args.power}" - - bot = SingleBotPlayer( - hostname=args.hostname, - port=args.port, - username=args.username, - password=args.password, - power_name=args.power, - model_name=args.model, - game_id=args.game_id, - negotiation_rounds=args.negotiation_rounds, - connection_timeout=args.connection_timeout, - max_retries=args.max_retries, - retry_delay=args.retry_delay, - ) - - await bot.run() - - except KeyboardInterrupt: - logger.info("Received keyboard interrupt") - except Exception as e: - logger.error(f"Fatal error in main: {e}", exc_info=True) - finally: - if bot: - # Ensure cleanup happens even if there was an error - try: - await bot.cleanup() - except Exception as cleanup_error: - logger.error(f"Error during final cleanup: {cleanup_error}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/bot_client/src/utils.py b/bot_client/src/utils.py new file mode 100644 index 0000000..83e9683 --- /dev/null +++ b/bot_client/src/utils.py @@ -0,0 +1,283 @@ +""" +@deprecated + + +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 +from config import config + + +async def create_game(hostname="localhost", port=8432, password="password", 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"bot_{creator_power}" + client = await connect_to_diplomacy_server( + hostname=hostname, + port=port, + username=creator_username, + password=password, + ) + + # Create the game + # TODO: Make more of the rules come from the config file + _ = await client.create_game( + map_name="standard", + rules=config.DEFAULT_GAME_RULES, + 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 + + +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 + + async def run_full_game( + self, + models: Optional[Dict[str, str]] = None, + log_level: str = "INFO", + creator_power: str = "FRANCE", + negotiation_rounds: int = 3, + connection_timeout: float = 30.0, + max_retries: int = 3, + retry_delay: float = 2.0, + ): + """ + 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, + connection_timeout=connection_timeout, + max_retries=max_retries, + retry_delay=retry_delay, + ) + + # 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, + connection_timeout: float = 30.0, + max_retries: int = 3, + retry_delay: float = 2.0, + ): + """ + 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, + connection_timeout=connection_timeout, + max_retries=max_retries, + retry_delay=retry_delay, + ) + + # 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)", + ) + parser.add_argument( + "--connection-timeout", + type=float, + default=30.0, + help="Timeout for network operations in seconds (default: 30.0)", + ) + parser.add_argument( + "--max-retries", + type=int, + default=3, + help="Maximum number of retries for failed operations (default: 3)", + ) + parser.add_argument( + "--retry-delay", + type=float, + default=2.0, + help="Base delay between retries in seconds (default: 2.0)", + ) + + return parser.parse_args() + + +async def main(): + """Main entry point.""" + + # FIXME: Arg parse appears to not like game ids with hypens in the name. e.g. + # uv run python multi_bot_launcher.py --game-id "-1D0i-fobmvprIh1" results in an error + 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, + connection_timeout=args.connection_timeout, + max_retries=args.max_retries, + retry_delay=args.retry_delay, + ) + 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, + connection_timeout=args.connection_timeout, + max_retries=args.max_retries, + retry_delay=args.retry_delay, + ) + + 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()) diff --git a/bot_client/websocket_diplomacy_client.py b/bot_client/src/websocket_diplomacy_client.py similarity index 100% rename from bot_client/websocket_diplomacy_client.py rename to bot_client/src/websocket_diplomacy_client.py diff --git a/bot_client/websocket_negotiations.py b/bot_client/src/websocket_negotiations.py similarity index 100% rename from bot_client/websocket_negotiations.py rename to bot_client/src/websocket_negotiations.py diff --git a/diplomacy/Dockerfile.backend b/diplomacy/Dockerfile.backend new file mode 100644 index 0000000..b076517 --- /dev/null +++ b/diplomacy/Dockerfile.backend @@ -0,0 +1,22 @@ +FROM python:3.8-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy setup files +COPY setup.py ./ +COPY . ./diplomacy/ +COPY README.md /app/ + +# Install Python dependencies +RUN pip install -e . + +# Expose the default diplomacy server port +EXPOSE 8432 + +# Run the diplomacy server +CMD ["python", "-m", "diplomacy.server.run"] diff --git a/diplomacy/Dockerfile.frontend b/diplomacy/Dockerfile.frontend new file mode 100644 index 0000000..737df16 --- /dev/null +++ b/diplomacy/Dockerfile.frontend @@ -0,0 +1,12 @@ +FROM node:lts-bullseye + +WORKDIR /app/web + +COPY web/package*.json ./ +RUN npm install + +COPY . /app/ + +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/docker-compose.yaml b/docker-compose.yaml index 0340730..9ddb060 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,7 +12,7 @@ services: shm_size: "2gb" diplomacy: - build: + build: context: ai_animation args: - VITE_ELEVENLABS_API_KEY=${VITE_ELEVENLABS_API_KEY} @@ -32,3 +32,19 @@ services: command: ["npm", "run", "dev-all"] volumes: - "./ai_animation/:/app/" + + old-diplomacy-fe: + build: + context: diplomacy + dockerfile: Dockerfile.frontend + ports: + - "3000:3000" + + old-diplomacy-be: + build: + context: diplomacy + dockerfile: Dockerfile.backend + ports: + - "8432:8432" + volumes: + - "./data/:/app/data"