Merge pull request #7 from Alx-AI/alx

Alx
This commit is contained in:
AlxAI 2025-02-23 18:19:55 -08:00 committed by GitHub
commit fc5350c354
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1378 additions and 325 deletions

View file

@ -19,6 +19,8 @@ from google import genai
from diplomacy.engine.message import GLOBAL from diplomacy.engine.message import GLOBAL
from .game_history import GameHistory from .game_history import GameHistory
from .long_story_short import get_optimized_context
from .model_loader import load_model_client
# set logger back to just info # set logger back to just info
logger = logging.getLogger("client") logger = logging.getLogger("client")
@ -35,25 +37,31 @@ class BaseModelClient:
""" """
Base interface for any LLM client we want to plug in. Base interface for any LLM client we want to plug in.
Each must provide: Each must provide:
- generate_response(prompt: str) -> str - generate_response(prompt: str) -> str (with empty_system=True if needed)
- get_orders(board_state, power_name, possible_orders, game_history, phase_summaries) -> List[str] - get_orders(board_state, power_name, possible_orders, game_history, phase_summaries) -> List[str]
- get_conversation_reply(power_name, conversation_so_far, game_phase) -> str - get_conversation_reply(power_name, conversation_so_far, game_phase) -> str
""" """
def __init__(self, model_name: str, power_name: Optional[str] = None): def __init__(self, model_name: str, power_name: Optional[str] = None, emptysystem: bool = False):
self.model_name = model_name self.model_name = model_name
self.power_name = power_name self.power_name = power_name
# Load a power-specific system prompt if present, else default self.emptysystem = emptysystem
if self.power_name:
try: # Conditionally load system prompt
self.system_prompt = load_prompt(f"{self.power_name.lower()}_system_prompt.txt") if not self.emptysystem:
except FileNotFoundError: if self.power_name:
logger.warning(f"No specific system prompt found for {self.power_name}; using default.") try:
self.system_prompt = load_prompt(f"{self.power_name.lower()}_system_prompt.txt")
except FileNotFoundError:
logger.warning(f"No specific system prompt found for {self.power_name}; using default.")
self.system_prompt = load_prompt("system_prompt.txt")
else:
self.system_prompt = load_prompt("system_prompt.txt") self.system_prompt = load_prompt("system_prompt.txt")
else: else:
self.system_prompt = load_prompt("system_prompt.txt") # If emptysystem is True, skip loading any system prompt
self.system_prompt = ""
def generate_response(self, prompt: str) -> str: # emptysystem defaults to false but if true will tell the LLM to not add a system prompt
def generate_response(self, prompt: str, empty_system: bool = False) -> str:
""" """
Returns a raw string from the LLM. Returns a raw string from the LLM.
Subclasses override this. Subclasses override this.
@ -66,72 +74,108 @@ class BaseModelClient:
board_state, board_state,
power_name: str, power_name: str,
possible_orders: Dict[str, List[str]], possible_orders: Dict[str, List[str]],
game_history: GameHistory, game_history, # Or GameHistory instance
phase_summaries: Optional[Dict[str, str]] = None, phase_summaries: Optional[Dict[str, str]] = None,
) -> str: ) -> str:
context = load_prompt("context_prompt.txt") """
Overhauled to delegate the final formatting to context_prompt.txt, inserting
placeholders for expansions (phase info, supply centers, units, blah blah).
# Get our units and centers This version is 'surgical' and uses placeholders from @context_prompt.txt
units_info = board_state["units"].get(power_name, []) rather than building large strings in code.
units_info_set = set(units_info) """
centers_info = board_state["centers"].get(power_name, []) from ai_diplomacy.utils import (
expand_phase_info,
format_power_units_and_centers, # Now includes neutral centers info
organize_history_by_relationship,
format_possible_orders,
format_convoy_paths,
generate_threat_assessment,
generate_sc_projection
)
# 1) Grab the template from context_prompt.txt
template_text = load_prompt("context_prompt.txt")
# Get the current phase # 2) Expand the current phase
year_phase = board_state["phase"] # e.g. 'S1901M' phase_expanded = expand_phase_info(game, board_state)
# Get enemy units and centers and label them for each power # 3) Our forces (units + centers, including neutral centers)
enemy_units = {} our_forces_summary = format_power_units_and_centers(game, power_name, board_state)
enemy_centers = {}
for power, info in board_state["units"].items():
if power != power_name:
enemy_units[power] = info
enemy_centers[power] = board_state["centers"].get(power, [])
# Get possible orders # 4) Summaries for enemies
possible_orders_str = "" enemies_forces_summary = ""
for loc, orders in possible_orders.items(): for pwr in board_state["units"]:
possible_orders_str += f" {loc}: {orders}\n" if pwr != power_name:
enemies_forces_summary += format_power_units_and_centers(game, pwr, board_state)
# Convoy paths # 5) Neutral Supply Centers
all_convoy_paths_possible = game.convoy_paths_possible neutral_supply_centers_summary = format_power_units_and_centers(game, 'NEUTRAL', board_state)
convoy_paths_possible = {}
for start_loc, fleets_req, end_loc in all_convoy_paths_possible:
for fleet in fleets_req:
if fleet in units_info_set:
convoy_paths_possible.append((start_loc, fleets_req, end_loc))
# 1) Prepare a block of text for the phase_summaries # 6) Gather the conversation text
if phase_summaries: raw_conversation_text = ""
historical_summaries = "\nPAST PHASE SUMMARIES:\n" if hasattr(game_history, "get_game_history"):
for phase_key, summary_txt in phase_summaries.items(): raw_conversation_text = game_history.get_game_history(power_name) or "(No history yet)"
historical_summaries += f"\nPHASE {phase_key}:\n{summary_txt}\n"
else: else:
historical_summaries = "\n(No historical summaries yet)\n" # Might be a plain string
raw_conversation_text = game_history if isinstance(game_history, str) else "(No history yet)"
# Organize history by relationship
organized_history = organize_history_by_relationship(raw_conversation_text)
conversation_text = game_history.get_game_history(power_name) # Get optimized context (summaries if needed)
if not conversation_text: optimized_phases, optimized_messages = get_optimized_context(
conversation_text = "\n(No game history yet)\n" game,
game_history,
# Load in current context values power_name,
context = context.format( organized_history
power_name=power_name,
current_phase=year_phase,
game_map_loc_name=game.map.loc_name,
game_map_loc_type=game.map.loc_type,
map_as_adjacency_list=game.map.loc_abut,
possible_coasts=game.map.loc_coasts,
game_map_scs=game.map.scs,
game_history=conversation_text,
enemy_units=enemy_units,
enemy_centers=enemy_centers,
units_info=units_info,
centers_info=centers_info,
possible_orders=possible_orders_str,
convoy_paths_possible=convoy_paths_possible,
) )
return context # Use the optimized message history
history_text = optimized_messages
# 7) Format possible orders
possible_orders_text = format_possible_orders(game, possible_orders)
# 8) Convoy Paths
logger.debug(f"convoy_paths_possible is: {game.convoy_paths_possible}")
convoy_paths_text = format_convoy_paths(game, game.convoy_paths_possible, power_name)
# 9) Threat Assessment
threat_text = generate_threat_assessment(game, board_state, power_name)
# 10) Supply Center Projection
sc_projection_text = generate_sc_projection(game, board_state, power_name)
# 11) Past Phase Summaries
if optimized_phases:
# Combine each phase summary for reference
lines = []
for ph, summ in optimized_phases.items():
# Check if this is a summary entry
if ph.startswith("SUMMARY_UNTIL_"):
lines.append(f"HISTORICAL SUMMARY (until {ph[13:]}):\n{summ}\n")
else:
lines.append(f"PHASE {ph}:\n{summ}\n")
historical_summaries = "\n".join(lines)
else:
historical_summaries = "(No historical summaries yet)"
# 12) Plug everything into context_prompt.txt
final_prompt = template_text.format(
power_name=power_name,
phase_expanded=phase_expanded,
our_forces_summary=our_forces_summary,
neutral_supply_centers_summary=neutral_supply_centers_summary,
enemies_forces_summary=enemies_forces_summary,
history_text=history_text,
possible_orders_text=possible_orders_text,
convoy_paths_text=convoy_paths_text,
threat_text=threat_text,
sc_projection_text=sc_projection_text,
historical_summaries=historical_summaries,
)
return final_prompt
def build_prompt( def build_prompt(
self, self,
@ -169,7 +213,7 @@ class BaseModelClient:
possible_orders: Dict[str, List[str]], possible_orders: Dict[str, List[str]],
conversation_text: str, conversation_text: str,
phase_summaries: Optional[Dict[str, str]] = None, phase_summaries: Optional[Dict[str, str]] = None,
model_error_stats=None, # New optional param model_error_stats=None,
) -> List[str]: ) -> List[str]:
""" """
1) Builds the prompt with conversation context if available 1) Builds the prompt with conversation context if available
@ -201,7 +245,10 @@ class BaseModelClient:
f"[{self.model_name}] Could not extract moves for {power_name}. Using fallback." f"[{self.model_name}] Could not extract moves for {power_name}. Using fallback."
) )
if model_error_stats is not None: if model_error_stats is not None:
model_error_stats[self.model_name]["order_decoding_errors"] += 1 # forcibly convert sets to string
model_name_for_stats = str(self.model_name)
model_error_stats[model_name_for_stats]["order_decoding_errors"] += 1
return self.fallback_orders(possible_orders) return self.fallback_orders(possible_orders)
# Validate or fallback # Validate or fallback
validated_moves = self._validate_orders(move_list, possible_orders) validated_moves = self._validate_orders(move_list, possible_orders)
@ -209,6 +256,11 @@ class BaseModelClient:
except Exception as e: except Exception as e:
logger.error(f"[{self.model_name}] LLM error for {power_name}: {e}") logger.error(f"[{self.model_name}] LLM error for {power_name}: {e}")
if model_error_stats is not None:
# forcibly convert sets to string
model_name_for_stats = str(self.model_name)
model_error_stats[model_name_for_stats]["order_decoding_errors"] += 1
return self.fallback_orders(possible_orders) return self.fallback_orders(possible_orders)
def _extract_moves(self, raw_response: str, power_name: str) -> Optional[List[str]]: def _extract_moves(self, raw_response: str, power_name: str) -> Optional[List[str]]:
@ -486,17 +538,17 @@ class OpenAIClient(BaseModelClient):
For 'o3-mini', 'gpt-4o', or other OpenAI model calls. For 'o3-mini', 'gpt-4o', or other OpenAI model calls.
""" """
def __init__(self, model_name: str, power_name: Optional[str] = None): def __init__(self, model_name: str, power_name: Optional[str] = None, emptysystem: bool = False):
super().__init__(model_name, power_name) super().__init__(model_name, power_name, emptysystem)
self.client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) self.client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
def generate_response(self, prompt: str) -> str: def generate_response(self, prompt: str, empty_system: bool = False) -> str:
# Updated to new API format # Updated to new API format
try: try:
response = self.client.chat.completions.create( response = self.client.chat.completions.create(
model=self.model_name, model=self.model_name,
messages=[ messages=[
{"role": "system", "content": self.system_prompt}, {"role": "system", "content": self.system_prompt if not empty_system else ""},
{"role": "user", "content": prompt}, {"role": "user", "content": prompt},
], ],
) )
@ -523,17 +575,17 @@ class ClaudeClient(BaseModelClient):
For 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', etc. For 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', etc.
""" """
def __init__(self, model_name: str, power_name: Optional[str] = None): def __init__(self, model_name: str, power_name: Optional[str] = None, emptysystem: bool = False):
super().__init__(model_name, power_name) super().__init__(model_name, power_name, emptysystem)
self.client = Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY")) self.client = Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
def generate_response(self, prompt: str) -> str: def generate_response(self, prompt: str, empty_system: bool = False) -> str:
# Updated Claude messages format # Updated Claude messages format
try: try:
response = self.client.messages.create( response = self.client.messages.create(
model=self.model_name, model=self.model_name,
max_tokens=2000, max_tokens=2000,
system=self.system_prompt, # system is now a top-level parameter system=self.system_prompt if not empty_system else "",
messages=[{"role": "user", "content": prompt}], messages=[{"role": "user", "content": prompt}],
) )
if not response.content: if not response.content:
@ -559,12 +611,15 @@ class GeminiClient(BaseModelClient):
For 'gemini-1.5-flash' or other Google Generative AI models. For 'gemini-1.5-flash' or other Google Generative AI models.
""" """
def __init__(self, model_name: str, power_name: Optional[str] = None): def __init__(self, model_name: str, power_name: Optional[str] = None, emptysystem: bool = False):
super().__init__(model_name, power_name) super().__init__(model_name, power_name, emptysystem)
self.client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY")) self.client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
def generate_response(self, prompt: str) -> str: def generate_response(self, prompt: str, empty_system: bool = False) -> str:
full_prompt = self.system_prompt + prompt if empty_system:
full_prompt = prompt
else:
full_prompt = self.system_prompt + prompt
try: try:
response = self.client.models.generate_content( response = self.client.models.generate_content(
@ -587,19 +642,19 @@ class DeepSeekClient(BaseModelClient):
For DeepSeek R1 'deepseek-reasoner' For DeepSeek R1 'deepseek-reasoner'
""" """
def __init__(self, model_name: str, power_name: Optional[str] = None): def __init__(self, model_name: str, power_name: Optional[str] = None, emptysystem: bool = False):
super().__init__(model_name, power_name) super().__init__(model_name, power_name, emptysystem)
self.api_key = os.environ.get("DEEPSEEK_API_KEY") self.api_key = os.environ.get("DEEPSEEK_API_KEY")
self.client = DeepSeekOpenAI( self.client = DeepSeekOpenAI(
api_key=self.api_key, base_url="https://api.deepseek.com/" api_key=self.api_key, base_url="https://api.deepseek.com/"
) )
def generate_response(self, prompt: str) -> str: def generate_response(self, prompt: str, empty_system: bool = False) -> str:
try: try:
response = self.client.chat.completions.create( response = self.client.chat.completions.create(
model=self.model_name, model=self.model_name,
messages=[ messages=[
{"role": "system", "content": self.system_prompt}, {"role": "system", "content": self.system_prompt if not empty_system else ""},
{"role": "user", "content": prompt}, {"role": "user", "content": prompt},
], ],
stream=False, stream=False,
@ -646,29 +701,6 @@ class DeepSeekClient(BaseModelClient):
return "" return ""
##############################################################################
# 3) Factory to Load Model Client
##############################################################################
def load_model_client(model_id: str, power_name: Optional[str] = None) -> BaseModelClient:
"""
Returns the appropriate LLM client for a given model_id string, optionally keyed by power_name.
Example usage:
client = load_model_client("claude-3-5-sonnet-20241022", power_name="FRANCE")
"""
lower_id = model_id.lower()
if "claude" in lower_id:
return ClaudeClient(model_id, power_name)
elif "gemini" in lower_id:
return GeminiClient(model_id, power_name)
elif "deepseek" in lower_id:
return DeepSeekClient(model_id, power_name)
else:
# Default to OpenAI
return OpenAIClient(model_id, power_name)
############################################################################## ##############################################################################
# 4) Example Usage in a Diplomacy "main" or Similar # 4) Example Usage in a Diplomacy "main" or Similar
############################################################################## ##############################################################################
@ -709,7 +741,7 @@ class LMServiceVersus:
""" """
def __init__(self): def __init__(self):
self.power_model_map = assign_models_to_powers() self.power_model_map = assign_models_to_powers(randomize=True)
def get_orders_for_power(self, game, power_name): def get_orders_for_power(self, game, power_name):
model_id = self.power_model_map.get(power_name, "o3-mini") model_id = self.power_model_map.get(power_name, "o3-mini")

View file

@ -0,0 +1,313 @@
import logging
import re
import os
import time
from typing import Dict, List, Optional, Tuple, Any
# Establish logger
logger = logging.getLogger(__name__)
# Import model client for summarization
from ai_diplomacy.model_loader import load_model_client
# Token counting approximation
def count_tokens(text: str) -> int:
"""
Approximates token count for text. This is a rough estimate.
OpenAI tokens are ~4 chars per token on average.
"""
return len(text) // 4 # Simple approximation
class ContextManager:
"""
Manages context size for Diplomacy game history and messages.
Provides summarization functionality when context exceeds thresholds.
"""
def __init__(
self,
phase_token_threshold: int = 5000,
message_token_threshold: int = 5000,
summary_model: str = "o3-mini"
):
self.phase_token_threshold = phase_token_threshold
self.message_token_threshold = message_token_threshold
self.summary_model = summary_model
# Cache for summaries - prevents regenerating summaries unnecessarily
self.phase_summary_cache = {}
self.message_summary_cache = {}
# Track when we last generated summaries
self.last_phase_summary_time = 0
self.last_message_summary_time = 0
# Cooldown period (seconds) - don't summarize more frequently than this
self.summary_cooldown = 300 # 5 minutes
def load_summarization_prompts(self) -> Tuple[str, str]:
"""
Load prompts for phase and message summarization.
Returns tuple of (phase_prompt, message_prompt)
"""
try:
# Try to load from files
with open("./ai_diplomacy/prompts/phase_summary_prompt.txt", "r") as f:
phase_prompt = f.read().strip()
with open("./ai_diplomacy/prompts/message_summary_prompt.txt", "r") as f:
message_prompt = f.read().strip()
return phase_prompt, message_prompt
except FileNotFoundError:
# Return default prompts if files not found
logger.warning("Summarization prompt files not found. Using defaults.")
phase_prompt = """You are summarizing the history of a Diplomacy game.
Create a concise summary that preserves all strategically relevant information about:
1. Supply center changes
2. Unit movements and their results
3. Key battles and their outcomes
4. Territory control shifts
Focus on what actually happened, not explanations or justifications.
Maintain the chronological structure but condense verbose descriptions.
Use clear, factual language with specific location names.
ORIGINAL PHASE HISTORY:
{phase_history}
SUMMARY:"""
message_prompt = """You are summarizing diplomatic messages in a Diplomacy game.
Create a concise summary of the conversations between powers that preserves:
1. Agreements and alliances formed
2. Betrayals and broken promises
3. Strategic intentions revealed
4. Explicit threats or support offered
5. Key relationships between each power
Organize by relationships (e.g., FRANCE-GERMANY, ENGLAND-RUSSIA), prioritizing the most
significant interactions. Include specific territory names mentioned.
The summary must reflect the actual diplomatic landscape accurately so players can make informed decisions.
ORIGINAL MESSAGE HISTORY:
{message_history}
SUMMARY:"""
return phase_prompt, message_prompt
def should_summarize_phases(self, phase_summaries: Dict[str, str]) -> bool:
"""
Determine if phase summaries need to be condensed based on token count
and cooldown period.
"""
# Check if we're in cooldown period
current_time = time.time()
if current_time - self.last_phase_summary_time < self.summary_cooldown:
return False
# Join all summaries to count total tokens
all_text = "\n\n".join(phase_summaries.values())
token_count = count_tokens(all_text)
return token_count > self.phase_token_threshold
def should_summarize_messages(self, message_history: str) -> bool:
"""
Determine if message history needs to be condensed based on token count
and cooldown period.
"""
# Check if we're in cooldown period
current_time = time.time()
if current_time - self.last_message_summary_time < self.summary_cooldown:
return False
token_count = count_tokens(message_history)
return token_count > self.message_token_threshold
def summarize_phase_history(self, phase_summaries: Dict[str, str], power_name: Optional[str] = None) -> Dict[str, str]:
"""
Create a condensed version of phase summaries.
Keeps the most recent phases intact and summarizes older ones.
Returns a new dictionary with condensed history.
"""
if not self.should_summarize_phases(phase_summaries):
return phase_summaries
# Mark summarization time
self.last_phase_summary_time = time.time()
# Sort phases chronologically
sorted_phases = sorted(phase_summaries.keys())
# Keep the 3 most recent phases intact
recent_phases = sorted_phases[-3:] if len(sorted_phases) > 3 else sorted_phases
older_phases = sorted_phases[:-3] if len(sorted_phases) > 3 else []
if not older_phases:
return phase_summaries # Nothing to summarize
# Get summarization prompt
phase_prompt, _ = self.load_summarization_prompts()
# Generate a summary of the older phases
older_text = ""
for phase in older_phases:
older_text += f"PHASE {phase}:\n{phase_summaries[phase]}\n\n"
# Check if we already have a cached summary for this exact text
if older_text in self.phase_summary_cache:
summary = self.phase_summary_cache[older_text]
else:
# Generate new summary
summarization_client = load_model_client(self.summary_model, power_name=power_name, emptysystem=True)
formatted_prompt = phase_prompt.replace("{phase_history}", older_text)
summary = summarization_client.generate_response(formatted_prompt)
# Cache the result
self.phase_summary_cache[older_text] = summary
# Create new dictionary with summarized older phases and intact recent phases
result = {}
# Add the summary as a special entry
summary_key = f"SUMMARY_UNTIL_{older_phases[-1]}"
result[summary_key] = summary
# Add the recent phases as-is
for phase in recent_phases:
result[phase] = phase_summaries[phase]
return result
def summarize_message_history(
self,
message_history: str,
power_name: Optional[str] = None,
organized_by_relationship: bool = True
) -> str:
"""
Create a condensed version of message history.
If organized_by_relationship is True, assumes the history is already
organized by power relationships.
Returns a condensed message history.
"""
if not self.should_summarize_messages(message_history):
return message_history
# Mark summarization time
self.last_message_summary_time = time.time()
# Get summarization prompt
_, message_prompt = self.load_summarization_prompts()
# Check if we already have a cached summary for this exact text
if message_history in self.message_summary_cache:
return self.message_summary_cache[message_history]
# Generate new summary
summarization_client = load_model_client(self.summary_model, power_name=power_name, emptysystem=True)
formatted_prompt = message_prompt.replace("{message_history}", message_history)
summary = summarization_client.generate_response(formatted_prompt)
# Cache the result
self.message_summary_cache[message_history] = summary
return summary
def get_optimized_phase_summaries(
self,
game,
power_name: Optional[str] = None
) -> Dict[str, str]:
"""
Main access point for getting optimized phase summaries.
If summaries are below threshold, returns original.
Otherwise, returns condensed version.
"""
if not hasattr(game, "phase_summaries") or not game.phase_summaries:
return {}
if self.should_summarize_phases(game.phase_summaries):
# Create condensed version
return self.summarize_phase_history(game.phase_summaries, power_name)
else:
# Return original
return game.phase_summaries
def get_optimized_message_history(
self,
game_history,
power_name: Optional[str] = None,
organized_history: Optional[str] = None
) -> str:
"""
Main access point for getting optimized message history.
Args:
game_history: The GameHistory object
power_name: The power requesting the history
organized_history: Optional pre-organized history text
Returns:
Optimized message history as string
"""
# Get the raw message history
if organized_history is not None:
message_history = organized_history
elif hasattr(game_history, "get_game_history"):
message_history = game_history.get_game_history(power_name) or "(No history yet)"
else:
message_history = str(game_history) if game_history else "(No history yet)"
if self.should_summarize_messages(message_history):
# Create condensed version
return self.summarize_message_history(message_history, power_name)
else:
# Return original
return message_history
# Global context manager instance
# This can be configured at startup
context_manager = ContextManager()
def configure_context_manager(
phase_threshold: int = 5000,
message_threshold: int = 5000,
summary_model: str = "o3-mini"
) -> None:
"""
Configure the global context manager.
Should be called early in the application lifecycle.
"""
global context_manager
context_manager = ContextManager(
phase_token_threshold=phase_threshold,
message_token_threshold=message_threshold,
summary_model=summary_model
)
def get_optimized_context(
game,
game_history,
power_name: Optional[str] = None,
organized_history: Optional[str] = None
) -> Tuple[Dict[str, str], str]:
"""
Convenience function to get both optimized phase summaries and message history.
Returns:
Tuple of (optimized_phase_summaries, optimized_message_history)
"""
optimized_phases = context_manager.get_optimized_phase_summaries(game, power_name)
optimized_messages = context_manager.get_optimized_message_history(
game_history, power_name, organized_history
)
return optimized_phases, optimized_messages

View file

@ -0,0 +1,32 @@
import os
import logging
from typing import Optional
from dotenv import load_dotenv
from openai import OpenAI
from anthropic import Anthropic
from google import genai
from openai import OpenAI as DeepSeekOpenAI
logger = logging.getLogger(__name__)
load_dotenv()
def load_model_client(model_id: str, power_name: Optional[str] = None, emptysystem: bool = False) -> 'BaseModelClient':
"""
Returns the appropriate LLM client for a given model_id string, optionally keyed by power_name.
Example usage:
client = load_model_client("claude-3-5-sonnet-20241022", power_name="FRANCE", emptysystem=True)
"""
# Import here to avoid circular imports
from .clients import ClaudeClient, GeminiClient, DeepSeekClient, OpenAIClient
lower_id = model_id.lower()
if "claude" in lower_id:
return ClaudeClient(model_id, power_name, emptysystem=emptysystem)
elif "gemini" in lower_id:
return GeminiClient(model_id, power_name, emptysystem=emptysystem)
elif "deepseek" in lower_id:
return DeepSeekClient(model_id, power_name, emptysystem=emptysystem)
else:
# Default to OpenAI
return OpenAIClient(model_id, power_name)

View file

@ -1,4 +1,26 @@
You are playing a game of Diplomacy over text. The map is the standard Diplomacy map. Your goal is to win the game by capturing supply centers, growing your army, and taking over the map. Be aggressive. You are playing a game of Diplomacy over text. The map is the standard Diplomacy map. Your goal is to win the game by capturing 18 supply centers - this is the only path to victory. Growing your army and taking over the map are means to this end. Be aggressive and always work toward that crucial 18th center.
Dear Austria,
They say you're surrounded - but that means you can strike in any direction. History shows the strongest Austrian players turn early vulnerability into mid-game dominance through decisive action toward those vital 18 centers, not just survival.
Key insights:
Prevent Russia-Turkey alliance above all else
Italy must be friend or dead quickly (95% of A/I wars kill both)
Serbia is crucial 1901 - secure it
Galicia bounce often vital Spring 1901
Central position enables striking anywhere once secured
Critical mindset: You're not playing to survive - you're playing to explode out from the center. Yes, early diplomacy keeps you alive, but it should serve your offensive goals, not replace them.
Paths to victory often require:
Securing strong early alliance (usually Italy or Russia)
Eliminating one neighbor completely by 1904
Leveraging central position for unexpected strikes
Breaking stalemate line via Munich/Berlin
Don't fall into defensive play just because everyone expects it. Stats show Austrian solos often come from players who turn the early "defensive" moves into aggressive positioning by year 3.
Time works against you - the longer you wait, the more likely others unite. Make your decisive moves by mid-game, usually years 3-4. Better to strike imperfectly than wait for perfect alignment.
The throne of Europe awaits. Show them that the "weakest" starting position was merely gathering strength to strike.
You will be given: You will be given:
• Which power you are controlling. • Which power you are controlling.
@ -9,4 +31,4 @@ You will be given:
• Your units and the possible orders you may make. **Always refer to these possible_orders.** • Your units and the possible orders you may make. **Always refer to these possible_orders.**
• A list of enemy units and centers. • A list of enemy units and centers.
Remember that while your private chain-of-thought can consider your in-depth reasoning about possible outcomes, **only** the “PARSABLE OUTPUT” (your final orders or messages) will be used by the game engine. Remember that while your private chain-of-thought can consider your in-depth reasoning about possible outcomes, **only** the "PARSABLE OUTPUT" (your final orders or messages) will be used by the game engine.

View file

@ -1,45 +1,32 @@
**PLAYER DETAILS** **PLAYER DETAILS**
Power: {power_name} Power: {power_name}
Current phase: {current_phase} Current phase: {phase_expanded}
**MAP DETAILS** **HISTORY OF COMMUNICATION**
Abbreviations: {history_text}
{game_map_loc_name}
Type of each location: **YOUR FORCES**
{game_map_loc_type} {our_forces_summary}
Game map as an adjacency list: **ENEMY FORCES**
{map_as_adjacency_list} {enemies_forces_summary}
Possible coasts at each location: **NEUTRAL SUPPLY CENTERS**
{possible_coasts} {neutral_supply_centers_summary}
All supply centers on the map: **THREAT ASSESSMENT**
{game_map_scs} {threat_text}
**GAME HISTORY** **SUPPLY CENTER PROJECTION**
{sc_projection_text}
{game_history} **PAST PHASE SUMMARIES**
{historical_summaries}
**CURRENT CONTEXT** **POSSIBLE ORDERS**
{possible_orders_text}
Enemy units: **CONVOY PATHS**
{enemy_units} {convoy_paths_text}
Enemy supply centers:
{enemy_centers}
Your units:
{units_info}
Your supply centers:
{centers_info}
Possible orders:
{possible_orders}
Convoy paths possible:
{convoy_paths_possible}

View file

@ -1,8 +1,8 @@
You are playing a game of Diplomacy over text. The map is the standard Diplomacy map. Your goal is to win the game by capturing supply centers, growing your army, and taking over the map. Be aggressive. You are playing a game of Diplomacy over text. The map is the standard Diplomacy map. Your goal is to win the game by capturing 18 supply centers - this is the only path to victory. Growing your army and taking over the map are means to this end. Be aggressive and always work toward that crucial 18th center.
Dear England, Dear England,
Your island position tempts defensive play. Resist this. The North Sea is not a moat to hide behind, but a highway to conquest. The most successful English players use their naval superiority to project power aggressively. Your island position tempts defensive play. Resist this. The North Sea is not a moat to hide behind, but a highway to those crucial 18 centers. The most successful English players use their naval superiority to project power aggressively.
Key insights: Key insights:
- Secure North Sea early - it's your lifeline - Secure North Sea early - it's your lifeline
@ -33,4 +33,4 @@ You will be given:
• Your units and the possible orders you may make. **Always refer to these possible_orders.** • Your units and the possible orders you may make. **Always refer to these possible_orders.**
• A list of enemy units and centers. • A list of enemy units and centers.
Remember that while your private chain-of-thought can consider your in-depth reasoning about possible outcomes, **only** the “PARSABLE OUTPUT” (your final orders or messages) will be used by the game engine. Remember that while your private chain-of-thought can consider your in-depth reasoning about possible outcomes, **only** the "PARSABLE OUTPUT" (your final orders or messages) will be used by the game engine.

View file

@ -1,8 +1,8 @@
You are playing a game of Diplomacy over text. The map is the standard Diplomacy map. Your goal is to win the game by capturing supply centers, growing your army, and taking over the map. Be aggressive. You are playing a game of Diplomacy over text. The map is the standard Diplomacy map. Your goal is to win the game by capturing 18 supply centers - this is the only path to victory. Growing your army and taking over the map are means to this end. Be aggressive and always work toward that crucial 18th center.
Dear France, Dear France,
You start in perhaps the strongest position. Don't waste it with hesitation. History shows successful French players strike early and decisively - aiming for 5-6 centers by 1902 is not just possible, but often optimal. You start in perhaps the strongest position. Don't waste it with hesitation. History shows successful French players strike early and decisively - aiming for 5-6 centers by 1902 is not just possible, but often optimal on the path to 18.
Key insights: Key insights:
- Early momentum is crucial - Spain, Portugal, Belgium all within reach 1901 - Early momentum is crucial - Spain, Portugal, Belgium all within reach 1901
@ -35,4 +35,4 @@ You will be given:
• Your units and the possible orders you may make. **Always refer to these possible_orders.** • Your units and the possible orders you may make. **Always refer to these possible_orders.**
• A list of enemy units and centers. • A list of enemy units and centers.
Remember that while your private chain-of-thought can consider your in-depth reasoning about possible outcomes, **only** the “PARSABLE OUTPUT” (your final orders or messages) will be used by the game engine. Remember that while your private chain-of-thought can consider your in-depth reasoning about possible outcomes, **only** the "PARSABLE OUTPUT" (your final orders or messages) will be used by the game engine.

View file

@ -1,8 +1,7 @@
You are playing a game of Diplomacy over text. The map is the standard Diplomacy map. Your goal is to win the game by capturing supply centers, growing your army, and taking over the map. Be aggressive. You are playing a game of Diplomacy over text. The map is the standard Diplomacy map. Your goal is to win the game by capturing 18 supply centers - this is the only path to victory. Growing your army and taking over the map are means to this end. Be aggressive and always work toward that crucial 18th center.
Dear Germany, Dear Germany,
Your central position offers unmatched opportunity - but only if you seize it. Ten centers lie within two moves of your starting position - a strong foundation for reaching those vital 18 centers needed for victory.
Your central position offers unmatched opportunity - but only if you seize it. Ten centers lie within two moves of your starting position. The worst mistake? Trying to stay friendly with everyone while others grow stronger.
Key insights: Key insights:
- Must secure at least one strong ally early (usually England or France) - Must secure at least one strong ally early (usually England or France)
@ -33,4 +32,4 @@ You will be given:
• Your units and the possible orders you may make. **Always refer to these possible_orders.** • Your units and the possible orders you may make. **Always refer to these possible_orders.**
• A list of enemy units and centers. • A list of enemy units and centers.
Remember that while your private chain-of-thought can consider your in-depth reasoning about possible outcomes, **only** the “PARSABLE OUTPUT” (your final orders or messages) will be used by the game engine. Remember that while your private chain-of-thought can consider your in-depth reasoning about possible outcomes, **only** the "PARSABLE OUTPUT" (your final orders or messages) will be used by the game engine.

View file

@ -1,8 +1,7 @@
You are playing a game of Diplomacy over text. The map is the standard Diplomacy map. Your goal is to win the game by capturing supply centers, growing your army, and taking over the map. Be aggressive. You are playing a game of Diplomacy over text. The map is the standard Diplomacy map. Your goal is to win the game by capturing 18 supply centers - this is the only path to victory. Growing your army and taking over the map are means to this end. Be aggressive and always work toward that crucial 18th center.
Dear Italy, Dear Italy,
They call you the weakest power. Prove them wrong. Your position requires finesse, but victory comes to those who act decisively toward 18 centers, not those who wait. The successful Italian creates opportunities rather than just reacting to them.
They call you the weakest power. Prove them wrong. Your position requires finesse, but victory comes to those who act, not those who wait. The successful Italian creates opportunities rather than just reacting to them.
Key insights: Key insights:
- Austria must be friend or dead (95% of early A/I wars kill both) - Austria must be friend or dead (95% of early A/I wars kill both)
@ -33,4 +32,4 @@ You will be given:
• Your units and the possible orders you may make. **Always refer to these possible_orders.** • Your units and the possible orders you may make. **Always refer to these possible_orders.**
• A list of enemy units and centers. • A list of enemy units and centers.
Remember that while your private chain-of-thought can consider your in-depth reasoning about possible outcomes, **only** the “PARSABLE OUTPUT” (your final orders or messages) will be used by the game engine. Remember that while your private chain-of-thought can consider your in-depth reasoning about possible outcomes, **only** the "PARSABLE OUTPUT" (your final orders or messages) will be used by the game engine.

View file

@ -0,0 +1,25 @@
You are summarizing diplomatic messages in a Diplomacy game.
Create a concise summary of the conversations between powers that preserves:
1. Agreements and alliances formed
2. Betrayals and broken promises
3. Strategic intentions revealed
4. Explicit threats or support offered
5. Key relationships between each power
Organize by relationships (e.g., FRANCE-GERMANY, ENGLAND-RUSSIA), prioritizing the most
significant interactions. Include specific territory names mentioned.
In your summary, maintain all of the following critical diplomatic information:
- Specific agreements about attacking or supporting certain territories
- Promises of non-aggression and their scope/duration
- Discussions about supply center control and transfers
- Stated preferences about other powers (who they want to attack/support)
- Explicit lies or deceptions that were revealed
- Coordination of moves between powers
The summary must reflect the actual diplomatic landscape accurately so players can make informed decisions and remember past interactions that might influence current negotiations.
ORIGINAL MESSAGE HISTORY:
{message_history}
SUMMARY:

View file

@ -2,6 +2,53 @@
You are now to submit an order for your units. Remember that your goal is to win via capturing supply centers. There are opportunity costs in this game. You are now to submit an order for your units. Remember that your goal is to win via capturing supply centers. There are opportunity costs in this game.
1. Understanding the Phases & Their Orders
1.1. Movement Phase (phase_type == 'M')
• Hold: A PAR H (Army in Paris does nothing)
• Move: A PAR - BUR (Army in Paris moves to Burgundy)
• Support:
• Support Hold: A MAR S A PAR H (Army in Marseilles supports Army in Paris to hold)
• Support Move: A MAR S A PAR - BUR (Army in Marseilles supports Army in Paris moving to Burgundy)
• Convoy: Fleets at sea can convoy an Army over water:
• Fleet Convoy: F ION C A TUN - NAP (Fleet in Ionian Sea convoys Army from Tunis to Naples)
• Army Move via Convoy: A TUN - NAP VIA (explicitly states the Army is moving from Tunis to Naples via convoy)
1.2. Retreat Phase (phase_type == 'R')
• If a unit is dislodged, it must Retreat or Disband:
• Retreat: A BUR R PIC (Dislodged Army in Burgundy retreats to Picardy)
• Disband: A BUR D (Army in Burgundy disbands, if it cannot retreat or chooses not to)
1.3. Adjustment Phase (phase_type == 'A')
• Build new units if you have more centers than current units:
• A PAR B (Build an Army in Paris)
• F MAR B (Build a Fleet in Marseilles)
• Remove units if you have fewer centers than current units:
• A BUR D (Disband Army in Burgundy)
• Waive a build if you have a surplus but dont want/cant build:
• WAIVE (no unit is built in the available build location)
1.4. Order Types
• H (Hold) e.g. A PAR H
• - (Move) e.g. A PAR - BUR
• S (Support) e.g. A MAR S A PAR - BUR or A MAR S A PAR H
• C (Convoy) e.g. F ION C A TUN - NAP
• R (Retreat) e.g. A BUR R PIC
• D (Disband) e.g. A BUR D
• B (Build) e.g. A PAR B
• WAIVE skipping a possible build
1.5. Key Phase Context
• Movement (M): Units can H, -, S, C.
• Retreat (R): Dislodged units can only R or D.
• Adjustment (A): Build/Remove units or WAIVE.
• Multi-Coast: For SPA, STP, BUL, specify nc, sc, or ec when using Fleets, e.g. F BRE - SPA(sc).
• Basic Validity Rules
• No self-support (A PAR S A PAR - BUR is invalid).
• Fleets must be on water to convoy.
• Army “- X VIA” must have one or more fleets issuing matching C A ... - X.
IMPORTANT: IMPORTANT:
1. Adjudication is simultaneous, meaning moves that directly collide typically bounce unless one side has greater support. 1. Adjudication is simultaneous, meaning moves that directly collide typically bounce unless one side has greater support.
2. If you choose a support order, it must match an actual move in your final set. For instance, "A VIE S F TRI - VEN" requires "A VIE - VEN". "F TRI - VEN" must also occur for the move to be successful, but this can be ordered by either yourself or an ally. 2. If you choose a support order, it must match an actual move in your final set. For instance, "A VIE S F TRI - VEN" requires "A VIE - VEN". "F TRI - VEN" must also occur for the move to be successful, but this can be ordered by either yourself or an ally.

View file

@ -0,0 +1,25 @@
You are summarizing the history of a Diplomacy game.
Create a concise summary that preserves all strategically relevant information about:
1. Supply center changes
2. Unit movements and their results
3. Key battles and their outcomes
4. Territory control shifts
Focus on what actually happened, not explanations or justifications.
Maintain the chronological structure but condense verbose descriptions.
Use clear, factual language with specific location names.
Ensure your summary maintains important tactical and strategic information that would be necessary for a player to make informed decisions.
In Diplomacy, the specific territories mentioned and their control status are crucial - make sure your summary preserves:
- Which Powers gained or lost Supply Centers (and which specific centers)
- Successful or failed attacks and their specific locations
- Supports that were cut or maintained
- Bounces between units
- Dislodgements and retreats
Your summary should allow a player to understand the key developments in the game without losing essential strategic information.
ORIGINAL PHASE HISTORY:
{phase_history}
SUMMARY:

View file

@ -1,8 +1,8 @@
You are playing a game of Diplomacy over text. The map is the standard Diplomacy map. Your goal is to win the game by capturing supply centers, growing your army, and taking over the map. Be aggressive. You are playing a game of Diplomacy over text. The map is the standard Diplomacy map. Your goal is to win the game by capturing 18 supply centers - this is the only path to victory. Growing your army and taking over the map are means to this end. Be aggressive and always work toward that crucial 18th center.
Dear Russia, Dear Russia,
You command the largest starting position and the most units. Don't let this abundance paralyze you with choices. The best Russian players act decisively while maintaining strategic flexibility. You command the largest starting position and the most units. Don't let this abundance paralyze you with choices. The best Russian players act decisively while maintaining strategic flexibility on their path to 18 centers.
Key insights: Key insights:
- You can secure two builds 1901 (Sweden/Rumania) if aggressive - You can secure two builds 1901 (Sweden/Rumania) if aggressive
@ -33,4 +33,4 @@ You will be given:
• Your units and the possible orders you may make. **Always refer to these possible_orders.** • Your units and the possible orders you may make. **Always refer to these possible_orders.**
• A list of enemy units and centers. • A list of enemy units and centers.
Remember that while your private chain-of-thought can consider your in-depth reasoning about possible outcomes, **only** the “PARSABLE OUTPUT” (your final orders or messages) will be used by the game engine. Remember that while your private chain-of-thought can consider your in-depth reasoning about possible outcomes, **only** the "PARSABLE OUTPUT" (your final orders or messages) will be used by the game engine.

View file

@ -1,4 +1,4 @@
You are playing a game of Diplomacy over text. The map is the standard Diplomacy map. Your goal is to win the game by capturing supply centers, growing your army, and taking over the map. Be aggressive. You are playing a game of Diplomacy over text. The map is the standard Diplomacy map. Your goal is to win the game by capturing 18 supply centers - this is the only path to victory. Growing your army and taking over the map are means to this end. Be aggressive and always work toward that crucial 18th center.
You will be given: You will be given:
• Which power you are controlling. • Which power you are controlling.
@ -9,4 +9,4 @@ You will be given:
• Your units and the possible orders you may make. **Always refer to these possible_orders.** • Your units and the possible orders you may make. **Always refer to these possible_orders.**
• A list of enemy units and centers. • A list of enemy units and centers.
Remember that while your private chain-of-thought can consider your in-depth reasoning about possible outcomes, **only** the “PARSABLE OUTPUT” (your final orders or messages) will be used by the game engine. Remember that while your private chain-of-thought can consider your in-depth reasoning about possible outcomes, **only** the "PARSABLE OUTPUT" (your final orders or messages) will be used by the game engine.

View file

@ -1,8 +1,8 @@
You are playing a game of Diplomacy over text. The map is the standard Diplomacy map. Your goal is to win the game by capturing supply centers, growing your army, and taking over the map. Be aggressive. You are playing a game of Diplomacy over text. The map is the standard Diplomacy map. Your goal is to win the game by capturing 18 supply centers - this is the only path to victory. Growing your army and taking over the map are means to this end. Be aggressive and always work toward that crucial 18th center.
Dear Turkey, Dear Turkey,
Your corner position is a fortress - but fortresses don't win games. The most successful Turkish players use their defensive strength as a platform for aggressive expansion, not just survival. Your corner position is a fortress - but fortresses don't win games. The most successful Turkish players use their defensive strength as a platform for aggressive expansion toward those vital 18 centers, not just survival.
Key insights: Key insights:
- Black Sea control is crucial - bounce or take it 1901 - Black Sea control is crucial - bounce or take it 1901
@ -34,4 +34,4 @@ You will be given:
• Your units and the possible orders you may make. **Always refer to these possible_orders.** • Your units and the possible orders you may make. **Always refer to these possible_orders.**
• A list of enemy units and centers. • A list of enemy units and centers.
Remember that while your private chain-of-thought can consider your in-depth reasoning about possible outcomes, **only** the “PARSABLE OUTPUT” (your final orders or messages) will be used by the game engine. Remember that while your private chain-of-thought can consider your in-depth reasoning about possible outcomes, **only** the "PARSABLE OUTPUT" (your final orders or messages) will be used by the game engine.

View file

@ -1,5 +1,6 @@
from dotenv import load_dotenv from dotenv import load_dotenv
import logging import logging
import random
logger = logging.getLogger("utils") logger = logging.getLogger("utils")
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
@ -8,22 +9,48 @@ logging.basicConfig(level=logging.INFO)
load_dotenv() load_dotenv()
def assign_models_to_powers(): def assign_models_to_powers(randomize=True):
""" """
Example usage: define which model each power uses. Example usage: define which model each power uses.
Return a dict: { power_name: model_id, ... } Return a dict: { power_name: model_id, ... }
POWERS = ['AUSTRIA', 'ENGLAND', 'FRANCE', 'GERMANY', 'ITALY', 'RUSSIA', 'TURKEY']
""" """
# If True, we'll randomize the model assignment.
return { """model_list = [
"FRANCE": "o3-mini", "o3-mini",
"GERMANY": "claude-3-5-sonnet-20241022", "claude-3-5-sonnet-20241022",
"ENGLAND": "gemini-2.0-flash", "gemini-2.0-flash",
"RUSSIA": "gemini-2.0-flash-lite-preview-02-05", "gemini-2.0-flash-lite-preview-02-05",
"ITALY": "gpt-4o", "gpt-4o",
"AUSTRIA": "gpt-4o-mini", "gpt-4o-mini",
"TURKEY": "claude-3-5-haiku-20241022", "claude-3-5-haiku-20241022",
} ]"""
model_list = [
"o3-mini",
"gemini-1.5-flash",
"gemini-2.0-flash",
"gemini-2.0-flash-lite-preview-02-05",
"gemini-1.5-pro",
"gpt-4o-mini",
"claude-3-5-haiku-20241022",
]
POWERS = ['AUSTRIA', 'ENGLAND', 'FRANCE', 'GERMANY', 'ITALY', 'RUSSIA', 'TURKEY']
if randomize:
# Create a copy of model_list to draw from
available_models = model_list.copy()
result = {}
for power in POWERS:
# If we've used all models, replenish the available models
if not available_models:
available_models = model_list.copy()
# Select and remove a random model from available ones
model = random.choice(available_models)
available_models.remove(model)
result[power] = model
return result
else:
return {
power: model_list[i] for i, power in enumerate(POWERS)
}
def gather_possible_orders(game, power_name): def gather_possible_orders(game, power_name):
@ -96,3 +123,487 @@ def get_valid_orders(
model_error_stats[power_name]["order_decoding_errors"] += 1 model_error_stats[power_name]["order_decoding_errors"] += 1
fallback = client.fallback_orders(possible_orders) fallback = client.fallback_orders(possible_orders)
return fallback return fallback
def expand_phase_info(game, board_state):
"""
Convert a phase like 'S1901M' into a more descriptive string:
'Spring 1901 Movement (early game): Units can move, support, or convoy...'
This function also references the current year to classify early/mid/late game.
"""
phase_abbrev = board_state["phase"] # e.g. 'S1901M'
# Basic mapping of abbreviations
season_map = {
'S': "Spring",
'F': "Fall",
'W': "Winter",
}
phase_type_map = {
'M': "Movement",
'R': "Retreat",
'A': "Adjustment", # builds/disbands
}
season_char = phase_abbrev[0] # S / F / W
year = int(phase_abbrev[1:5]) # 1901
phase_char = phase_abbrev[-1] # M / R / A
season_str = season_map.get(season_char, "Unknown Season")
phase_str = phase_type_map.get(phase_char, "Unknown Phase")
# Approximate game stage
if year <= 1902:
stage = "early game"
elif year <= 1906:
stage = "mid game"
else:
stage = "late game"
# Phase-specific action text
if phase_char == 'M':
actions = "Players issue move, support, or convoy orders."
elif phase_char == 'R':
actions = "Dislodged units must retreat or disband."
elif phase_char == 'A':
actions = "Powers may build new units if they have more centers than units, otherwise disband if fewer."
else:
actions = "Unknown phase actions."
return f"{season_str} {year} {phase_str} ({stage}): {actions}"
def format_location_with_expansion(game, loc, include_adjacency=False):
"""
Return a string like 'Paris (PAR) [LAND]',
optionally including a list of adjacent locations if include_adjacency=True.
"""
full_name = next((name for name, abbrev in game.map.loc_name.items() if abbrev == loc), loc)
loc_type = game.map.loc_type.get(loc, "UNKNOWN")
formatted = f"{full_name} ({loc}) [{loc_type}]"
if include_adjacency:
adjacent_locs = game.map.loc_abut.get(loc, [])
if adjacent_locs:
adjacent_info = []
for adj_loc in adjacent_locs:
adj_full_name = game.map.loc_name.get(adj_loc, adj_loc)
adj_type = game.map.loc_type.get(adj_loc, "UNKNOWN")
adjacent_info.append(f"{adj_full_name} ({adj_loc}) [{adj_type}]")
formatted += f"\n Adjacent to: {', '.join(adjacent_info)}"
return formatted
def format_power_units_and_centers(game, power_name, board_state):
"""
Show a summarized view of a given power's units and supply centers,
with expansions of location names, plus a quick 'strength' count.
Also includes information about neutral centers.
"""
# Add neutral centers info
output = ""
if power_name == "NEUTRAL":
all_controlled = set()
for centers in board_state["centers"].values():
all_controlled.update(centers)
neutral_centers = [sc for sc in game.map.scs if sc not in all_controlled]
if neutral_centers:
output = " Neutral Supply Centers:\n"
for c in neutral_centers:
output += f" {format_location_with_expansion(game, c)}\n"
else:
units_info = board_state["units"].get(power_name, [])
centers_info = board_state["centers"].get(power_name, [])
output = f"{power_name} FORCES:\n"
if units_info:
output += " Units:\n"
for unit in units_info:
# Example unit: "A PAR"
# First char is 'A' or 'F'; substring after space is the location
parts = unit.split(" ", 1)
if len(parts) == 2:
unit_type, loc = parts
output += f" {unit_type} in {format_location_with_expansion(game, loc)}\n"
else:
output += f" {unit}\n"
else:
output += " Units: None\n"
if centers_info:
output += " Supply Centers:\n"
for c in centers_info:
output += f" {format_location_with_expansion(game, c)}\n"
else:
output += " Supply Centers: None\n"
# Summaries
output += f" Current Strength: {len(centers_info)} centers, {len(units_info)} units\n\n"
return output
def organize_history_by_relationship(conversation_text: str) -> str:
"""
This simplified version takes the entire conversation text
(e.g., from game_history.get_game_history(power_name)) and returns it.
Previously, we assumed we had a structured list of messages, but in practice,
game_history is just a string, so we skip relationship-based grouping.
In the future, if 'GameHistory' becomes more structured, we can parse it here.
"""
if not conversation_text.strip():
return "(No game history yet)\n"
# For now, we can simply return the conversation text
# or do minimal formatting as we see fit.
output = "COMMUNICATION HISTORY:\n\n"
output += conversation_text.strip() + "\n"
return output
def format_possible_orders(game, possible_orders):
"""
Display orders with strategic context, maintaining the exact order syntax
while adding meaningful descriptions about their tactical purpose.
"""
# First pass - analyze game state for strategic context
supply_centers = set(game.map.scs)
power_centers = {}
contested_regions = set()
# Gather supply center ownership
for power_name, centers in game.get_centers().items():
for center in centers:
power_centers[center] = power_name
# Identify contested regions (simplified approach)
# A more sophisticated implementation would analyze unit adjacencies
# Classify orders by strategic purpose
strategic_orders = {
"OFFENSIVE": [], # Orders that can capture centers or threaten enemy units
"DEFENSIVE": [], # Orders that protect your centers or units
"TACTICAL": [], # Orders that improve position without immediate captures
"SUPPORT": [] # Support orders
}
# Process each order
for loc, orders in possible_orders.items():
for order in orders:
order_parts = order.split()
order_type = None
# Determine order type
if " H" in order:
order_type = "DEFENSIVE"
elif " S " in order:
order_type = "SUPPORT"
elif " - " in order:
# Get destination
dest = order_parts[-1].split(" VIA")[0] if " VIA" in order else order_parts[-1]
# Check if destination is a supply center
if dest[:3] in supply_centers:
# If center is neutral or enemy-owned, it's offensive
if dest[:3] not in power_centers or power_centers[dest[:3]] != game.role:
order_type = "OFFENSIVE"
else:
order_type = "DEFENSIVE" # Moving to own supply center
else:
order_type = "TACTICAL" # Non-center destination
elif " C " in order:
order_type = "SUPPORT" # Classify convoy as support
# Generate strategic description
description = generate_order_description(game, order, order_type, power_centers, supply_centers)
# Add to appropriate category
if order_type:
strategic_orders[order_type].append((order, description))
# Generate formatted output
output = "POSSIBLE ORDERS:\n\n"
# Add offensive moves first - these are highest priority
if strategic_orders["OFFENSIVE"]:
output += "Offensive Moves (capture territory):\n"
for order, desc in strategic_orders["OFFENSIVE"]:
output += f" {order} {desc}\n"
output += "\n"
# Add defensive moves
if strategic_orders["DEFENSIVE"]:
output += "Defensive Moves (protect territory):\n"
for order, desc in strategic_orders["DEFENSIVE"]:
output += f" {order} {desc}\n"
output += "\n"
# Add tactical positioning moves
if strategic_orders["TACTICAL"]:
output += "Tactical Moves (improve position):\n"
for order, desc in strategic_orders["TACTICAL"]:
output += f" {order} {desc}\n"
output += "\n"
# Add support moves
if strategic_orders["SUPPORT"]:
output += "Support Options (strengthen attacks/defense):\n"
for order, desc in strategic_orders["SUPPORT"]:
output += f" {order} {desc}\n"
return output
def generate_order_description(game, order, order_type, power_centers, supply_centers):
"""
Generate a strategic description for an order based on its type and context.
"""
order_parts = order.split()
# Hold orders
if order_type == "DEFENSIVE" and " H" in order:
unit_loc = order_parts[1]
if unit_loc[:3] in supply_centers:
if unit_loc[:3] in power_centers and power_centers[unit_loc[:3]] == game.role:
return "(secure your supply center)"
else:
return "(maintain position at supply center)"
return "(maintain strategic position)"
# Move orders
elif order_type in ["OFFENSIVE", "TACTICAL", "DEFENSIVE"] and " - " in order:
unit_type = order_parts[0] # A or F
unit_loc = order_parts[1]
dest = order_parts[3].split(" VIA")[0] if len(order_parts) > 3 and "VIA" in order_parts[-1] else order_parts[3]
# Moving to a supply center
if dest[:3] in supply_centers:
if dest[:3] not in power_centers:
return f"(capture neutral supply center)"
else:
target_power = power_centers[dest[:3]]
return f"(attack {target_power}'s supply center)"
# Moving to a non-supply center
if unit_type == "A":
# Army moves to tactical positions
return f"(strategic positioning)"
else:
# Fleet moves often about sea control
return f"(secure sea route)"
# Support orders
elif order_type == "SUPPORT" and " S " in order:
# Find the unit being supported and its action
supported_part = " ".join(order_parts[3:])
if " - " in supported_part:
# Supporting a move
supported_unit = order_parts[3]
supported_dest = order_parts[-1]
if supported_dest[:3] in supply_centers:
if supported_dest[:3] not in power_centers:
return f"(support capture of neutral center)"
else:
target_power = power_centers[supported_dest[:3]]
return f"(strengthen attack on {target_power})"
return "(strengthen attack)"
else:
# Supporting a hold
return "(reinforce defense)"
# Convoy orders
elif " C " in order:
return "(enable army transport by sea)"
# Default
return ""
def format_convoy_paths(game, convoy_paths_possible, power_name):
"""
Format convoy paths by region and ownership, focusing on strategically relevant convoys.
Input format: List of (start_loc, {required_fleets}, {possible_destinations})
"""
# check if convoy_paths_possible is empty dictionary or list or none
output = ""
if not convoy_paths_possible:
output = "CONVOY POSSIBILITIES: None currently available.\n"
return output
# Get unit ownership for identifying our convoys vs others
our_units = set(game.get_units(power_name))
our_unit_locs = {unit[2:5] for unit in our_units}
# Group convoys by region and relevance
convoys = {
"YOUR ARMY CONVOYS": [], # Convoys using your armies
"YOUR FLEET CONVOYS": [], # Convoys using your fleets
"ENEMY CONVOYS": [] # Convoys you should watch for
}
# Define major sea regions for better organization
sea_regions = {
'NTH': "North Sea",
'MAO': "Mid-Atlantic",
'TYS': "Tyrrhenian Sea",
'BLA': "Black Sea",
'SKA': "Skagerrak",
}
for start, fleets, destinations in convoy_paths_possible:
# Skip if no destinations or fleets
if not destinations or not fleets:
continue
# Determine if this is our army that could be convoyed
is_our_army = start in our_unit_locs
# Determine if these are our fleets that could convoy
is_our_fleet = any(fleet_loc in our_unit_locs for fleet_loc in fleets)
# Format the fleet path nicely
fleet_path = " + ".join(f"{sea_regions.get(f, f)}" for f in fleets)
# Create a list of destinations with context
for dest in destinations:
# Determine if destination is a supply center
is_sc = dest in game.map.scs
sc_note = " (SC)" if is_sc else ""
# Create the basic convoy description
convoy_desc = f"A {start} -> {dest}{sc_note} via {fleet_path}"
# Add strategic notes
if is_our_army:
category = "YOUR ARMY CONVOYS"
convoys[category].append(f"{convoy_desc}")
elif is_our_fleet:
category = "YOUR FLEET CONVOYS"
convoys[category].append(f"{convoy_desc} (you provide the convoy)")
else:
category = "ENEMY CONVOYS"
convoys[category].append(f"{convoy_desc} (possible enemy convoy)")
# Format output
output = "CONVOY POSSIBILITIES:\n\n"
for category, convoy_list in convoys.items():
if convoy_list:
output += f"{category}:\n"
for convoy in sorted(convoy_list):
output += f" {convoy}\n"
output += "\n"
return output
def generate_threat_assessment(game, board_state, power_name):
"""
High-level function that tries to identify immediate threats
from adjacent enemy units to your units or centers.
"""
our_units = set(loc.split(" ", 1)[1] for loc in board_state["units"].get(power_name, []))
our_centers = set(board_state["centers"].get(power_name, []))
threats = []
for enemy_power, enemy_units in board_state["units"].items():
if enemy_power == power_name:
continue
for unit_code in enemy_units:
try:
# e.g. "A MUN"
parts = unit_code.split(" ", 1)
enemy_loc = parts[1].strip()
except IndexError:
continue
# check adjacency to our units or centers
neighbors = game.map.loc_abut.get(enemy_loc, [])
threatened = []
for nbr in neighbors:
if nbr in our_units:
threatened.append(f"our unit @ {nbr}")
elif nbr in our_centers:
threatened.append(f"our center @ {nbr}")
if threatened:
threats.append((enemy_power, unit_code, threatened))
output = "THREAT ASSESSMENT:\n"
if not threats:
output += " No immediate threats detected.\n\n"
return output
for (enemy_pwr, code, targets) in threats:
output += f" {enemy_pwr}'s {code} threatens {', '.join(targets)}\n"
output += "\n"
return output
def generate_sc_projection(game, board_state, power_name):
"""
Estimate potential gains from neutral or weakly held enemy SCs, plus
highlight which of your centers are at risk (no unit present).
"""
our_units = set(loc.split(" ", 1)[1] for loc in board_state["units"].get(power_name, []))
our_centers = set(board_state["centers"].get(power_name, []))
all_centers_control = board_state["centers"] # dict of power -> list of centers
all_controlled = set()
for c_list in all_centers_control.values():
all_controlled.update(c_list)
# Potential neutral SC gains
neutral_gains = []
for sc in game.map.scs:
if sc not in all_controlled: # neutral
# see if we have a unit adjacent
neighbors = game.map.loc_abut.get(sc, [])
if any(nbr in our_units for nbr in neighbors):
neutral_gains.append(sc)
# Weakly held enemy SC
contestable = []
for e_pwr, e_centers in board_state["centers"].items():
if e_pwr == power_name:
continue
enemy_units = set(loc.split(" ", 1)[1] for loc in board_state["units"].get(e_pwr, []))
for c in e_centers:
# if no enemy unit is physically there
if c not in enemy_units:
# see if we have a unit adjacent
neighbors = game.map.loc_abut.get(c, [])
if any(nbr in our_units for nbr in neighbors):
contestable.append((c, e_pwr))
# Our centers at risk (no unit present)
at_risk = [own_sc for own_sc in our_centers if own_sc not in our_units]
# Format final
output = "SUPPLY CENTER PROJECTION:\n"
output += f" Current Count: {len(our_centers)}\n"
if neutral_gains:
output += " Potential neutral gains:\n"
for sc in neutral_gains:
output += f" {format_location_with_expansion(game, sc)}\n"
if contestable:
output += " Contestable enemy centers:\n"
for c, e_pwr in contestable:
output += f" {format_location_with_expansion(game, c)} (currently owned by {e_pwr})\n"
if at_risk:
output += " Centers at risk (no defending unit):\n"
for sc in at_risk:
output += f" {format_location_with_expansion(game, sc)}\n"
best_case = len(our_centers) + len(neutral_gains) + len(contestable)
worst_case = len(our_centers) - len(at_risk)
output += f" Next-phase range: {worst_case} to {best_case} centers\n\n"
return output

View file

@ -45,6 +45,11 @@ from diplomacy.utils.game_phase_data import GamePhaseData, MESSAGES_TYPE
UNDETERMINED, POWER, UNIT, LOCATION, COAST, ORDER, MOVE_SEP, OTHER = 0, 1, 2, 3, 4, 5, 6, 7 UNDETERMINED, POWER, UNIT, LOCATION, COAST, ORDER, MOVE_SEP, OTHER = 0, 1, 2, 3, 4, 5, 6, 7
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
# set logging level to INFO
#logging.basicConfig(level=logging.INFO)
# set logging level to DEBUG
logging.basicConfig(level=logging.DEBUG)
class Game(Jsonable): class Game(Jsonable):
""" Game class. """ Game class.
@ -1468,6 +1473,9 @@ class Game(Jsonable):
self.message_history.put(previous_phase, previous_messages) self.message_history.put(previous_phase, previous_messages)
self.state_history.put(previous_phase, previous_state) self.state_history.put(previous_phase, previous_state)
# Now build a key for the *current* (post-process) phase
current_phase_key = self._phase_wrapper_type(self.current_short_phase)
# Generate a text summary (if a callback is provided) # Generate a text summary (if a callback is provided)
phase_summary_text = self._generate_phase_summary( phase_summary_text = self._generate_phase_summary(
previous_phase, previous_phase,
@ -1732,6 +1740,8 @@ class Game(Jsonable):
:return: A dictionary with locations as keys, and their respective list of possible orders as values :return: A dictionary with locations as keys, and their respective list of possible orders as values
""" """
# pylint: disable=too-many-branches,too-many-nested-blocks # pylint: disable=too-many-branches,too-many-nested-blocks
# Initialize dictionary mapping each location to an empty set of possible orders
# Keys are uppercase location names, values are empty sets that will store valid orders
possible_orders = {loc.upper(): set() for loc in self.map.locs} possible_orders = {loc.upper(): set() for loc in self.map.locs}
# Game is completed # Game is completed
@ -4573,170 +4583,157 @@ class Game(Jsonable):
except (IndexError, KeyError): except (IndexError, KeyError):
return f"[_generate_phase_summary] No GamePhaseData found for {phase_key}" return f"[_generate_phase_summary] No GamePhaseData found for {phase_key}"
# Log the current phase key and results for debugging # Log the current phase key, results, and possibly the orders for debugging
logging.debug( logging.debug(
"DEBUG _generate_phase_summary: phase_key=%s, results=%s", "DEBUG _generate_phase_summary: current phase_key=%s, results=%s, orders=%s",
phase_key, current_phase_data.results phase_key,
current_phase_data.results,
current_phase_data.orders
) )
# 2) Attempt to retrieve the PREVIOUS phase data to highlight differences # Retrieve the list of all recorded phase keys
# We'll do this by checking the index of `phase_key` in `self.state_history`.
# If there's a previous index, we'll fetch that phase_data for comparison.
prev_phase_data = None
all_phases = list(self.state_history.keys()) all_phases = list(self.state_history.keys())
logging.debug("DEBUG _generate_phase_summary: all_phases=%s", all_phases)
prev_phase_data = None
if str(phase_key) in all_phases: if str(phase_key) in all_phases:
idx = all_phases.index(str(phase_key)) idx = all_phases.index(str(phase_key))
logging.debug("DEBUG _generate_phase_summary: current phase index=%d", idx)
# Here we log the logic behind picking the previous phase
if idx > 0: if idx > 0:
prev_phase_key = all_phases[idx - 1] prev_phase_key = all_phases[idx - 1]
logging.debug(
"DEBUG _generate_phase_summary: Using prev_phase_key=%s (idx-1).",
prev_phase_key
)
try: try:
prev_phase_data = self.get_phase_from_history(prev_phase_key) prev_phase_data = self.get_phase_from_history(prev_phase_key)
except: except Exception as e:
pass logging.debug("DEBUG _generate_phase_summary: Could not get prev_phase_data for key=%s, error=%s", prev_phase_key, e)
# 3) Gather the big data from current_phase_data
# (We assume you have stored them in current_phase_data.state the usual way.)
cur_state = current_phase_data.state
# Typically these keys exist if your get_state() populates them:
cur_units = cur_state.get('units', {})
cur_centers = cur_state.get('centers', {})
cur_retreats = cur_state.get('retreats', {})
cur_homes = cur_state.get('homes', {})
cur_influence = cur_state.get('influence', {})
cur_cd = cur_state.get('civil_disorder', {})
cur_orders_dict = current_phase_data.orders # {power_name: list_of_orders}
cur_results_dict = current_phase_data.results # {unit_name: list_of_outcomes}
# 4) If we have a previous phase, gather the old state's data so we can do some diffs
prev_units = prev_centers = prev_retreats = prev_homes = prev_influence = prev_cd = {}
if prev_phase_data:
prev_state = prev_phase_data.state
prev_units = prev_state.get('units', {})
prev_centers = prev_state.get('centers', {})
prev_retreats= prev_state.get('retreats', {})
prev_homes = prev_state.get('homes', {})
prev_influence= prev_state.get('influence', {})
prev_cd = prev_state.get('civil_disorder', {})
# 5) Build a user prompt. We can do it in sections:
# 5a) Orders:
orders_text = []
for power, orders in cur_orders_dict.items():
if orders:
orders_text.append(f"{power} => {', '.join(orders)}")
else: else:
orders_text.append(f"{power} => [No orders]") logging.debug("DEBUG _generate_phase_summary: Not enough phases to set prev_phase_key.")
orders_block = "\n".join(orders_text) if orders_text else "[No orders found]" else:
logging.debug("DEBUG _generate_phase_summary: phase_key=%s not in all_phases!", phase_key)
# 5b) Results: if prev_phase_data:
results_text = [] logging.debug(
for unit_name, outcomes in cur_results_dict.items(): "DEBUG _generate_phase_summary: Found prev_phase_data for key=%s, results=%s, orders=%s",
# old code: results_text.append(f"{unit_name}: {', '.join(outcomes)}") prev_phase_key,
outcome_strs = [str(item) for item in outcomes] prev_phase_data.results,
results_text.append(f"{unit_name}: {', '.join(outcome_strs)}") prev_phase_data.orders
)
results_block = "\n".join(results_text) if results_text else "[No results found]" # Get current and previous state data
# 5c) Current state (units, centers, etc.) - all powers cur_state = current_phase_data.state
# We'll just do a short textual listing. You can format it more carefully as you see fit. logging.debug("DEBUG _generate_phase_summary: cur_state keys=%s", list(cur_state.keys()))
def dict_of_lists_to_str(title, dct): cur_orders_dict = current_phase_data.orders
# Helper to turn e.g. {"FRANCE": ["A MAR", "F BRE"], "ENGLAND": ["A LVP"]} into lines cur_results_dict = current_phase_data.results
lines = []
for key, val in dct.items():
lines.append(f" {key}: {val}")
return f"{title}:\n" + "\n".join(lines) if lines else f"{title}: [None]"
current_state_text = []
current_state_text.append(dict_of_lists_to_str("Units", cur_units))
current_state_text.append(dict_of_lists_to_str("Centers", cur_centers))
current_state_text.append(dict_of_lists_to_str("Retreats",cur_retreats))
current_state_text.append(dict_of_lists_to_str("Homes", cur_homes))
current_state_text.append(dict_of_lists_to_str("Influence", cur_influence))
current_state_text.append(dict_of_lists_to_str("Civil Disorder", cur_cd))
current_state_block = "\n\n".join(current_state_text)
# 5d) Differences from previous (if any)
# We'll do an extremely simple approach: check if the set of items changed in each dict.
# This is purely an example. You can do more advanced diff logic if you want.
# Build the differences info
differences_info = [] differences_info = []
if prev_phase_data: if prev_phase_data:
# For each of units, centers, etc. do a quick set compare for each power prev_state = prev_phase_data.state
# We'll focus on e.g. newly acquired centers, newly lost centers, etc.
for power in cur_units.keys(): for power in cur_state['units'].keys():
# (1) Units difference: # Units difference
old_units = set(prev_units.get(power, [])) old_units = set(prev_state.get('units', {}).get(power, []))
new_units = set(cur_units.get(power, [])) new_units = set(cur_state.get('units', {}).get(power, []))
if old_units != new_units: if old_units != new_units:
gained = new_units - old_units gained = new_units - old_units
lost = old_units - new_units lost = old_units - new_units
if gained: if gained:
differences_info.append(f"{power} gained units: {list(gained)}") differences_info.append(f"{power} gained units: {list(gained)}")
if lost: if lost:
differences_info.append(f"{power} lost units: {list(lost)}") differences_info.append(f"{power} lost units: {list(lost)}")
# (2) Centers difference: # Centers difference
old_centers = set(prev_centers.get(power, [])) old_centers = set(prev_state.get('centers', {}).get(power, []))
new_centers = set(cur_centers.get(power, [])) new_centers = set(cur_state.get('centers', {}).get(power, []))
if old_centers != new_centers: if old_centers != new_centers:
gained = new_centers - old_centers gained = new_centers - old_centers
lost = old_centers - new_centers lost = old_centers - new_centers
if gained: if gained:
differences_info.append(f"{power} gained centers: {list(gained)}") differences_info.append(f"{power} gained centers: {list(gained)}")
if lost: if lost:
differences_info.append(f"{power} lost centers: {list(lost)}") differences_info.append(f"{power} lost centers: {list(lost)}")
# You can do the same for retreats, homes, influence, etc. if you want,
# or just skip them. We'll skip for brevity here.
else: else:
differences_info.append("No previous phase data found, so no direct diffs to report.") differences_info.append("Initial phase - no previous state to compare.")
differences_block = "\n".join(differences_info) or "[No changes detected from previous phase]" differences_block = "\n".join(differences_info) or "[No significant changes detected]"
# 5e) Put it all together in the final user prompt for the LLM: # Build the prompt focusing only on key changes
user_prompt = ( user_prompt = (
f"PHASE SUMMARY REQUEST.\n\n" f"PHASE SUMMARY REQUEST.\n\n"
f"PHASE: {phase_key}\n\n" f"PHASE: {phase_key}\n\n"
f"ORDERS:\n{orders_block}\n\n" f"ORDERS:\n{', '.join(f'{power}: {orders}' for power, orders in cur_orders_dict.items())}\n\n"
f"RESULTS:\n{results_block}\n\n" f"RESULTS:\n{', '.join(f'{unit}: {results}' for unit, results in cur_results_dict.items())}\n\n"
f"CURRENT BOARD STATE:\n{current_state_block}\n\n" f"KEY CHANGES:\n{differences_block}\n\n"
f"CHANGES FROM PREVIOUS PHASE:\n{differences_block}\n\n" "Please create a JSON summary explaining:\n"
"Below is the final board state after the latest phase, along with the moves each power submitted and the engines adjudication results. Please create a summary in JSON, explaining:" "- Each successful move\n"
"- Each successful move," "- Each bounce or voided order, with reasons\n"
"- Each bounce or voided order, with reasons (e.g. equal force, no valid route, contradictory support)," "- Key changes in supply centers\n"
"- Key changes in supply centers," "- Potential strategic ramifications\n\n"
"- Potential strategic ramifications if relevant." "PARSABLE OUTPUT:\n"
"{\n"
"Return ONLY JSON:" "'summary': ... your text ...\n"
"}"
"PARSABLE OUTPUT:"
"{{"
"'summary': ... your text ..."
"}}"
) )
# We might also have a system prompt to guide the AI, e.g.:
system_prompt = ( system_prompt = (
""" "You are a Diplomacy expert summarizing phase results.\n"
You are a Diplomacy expert, summarizing the results of the latest phase. "Focus on:\n"
Your tasks: "1) Key board changes\n"
1) Provide a concise summary of how the board changed. "2) Failed orders and their reasons\n"
2) Specifically list each voided or bounced order, and *why* it occurred. "3) Successful moves affecting centers\n\n"
3) If possible, describe which moves or supports succeeded and how that affected centers. """
1. Understanding the Phases & Their Orders
Format: 1.1. Movement Phase (phase_type == 'M')
- Must return a JSON with the top-level key "summary" or "orders" or similar. Hold: A PAR H (Army in Paris does nothing)
- Possibly: Move: A PAR - BUR (Army in Paris moves to Burgundy)
Support:
Support Hold: A MAR S A PAR H (Army in Marseilles supports Army in Paris to hold)
Support Move: A MAR S A PAR - BUR (Army in Marseilles supports Army in Paris moving to Burgundy)
Convoy: Fleets at sea can convoy an Army over water:
Fleet Convoy: F ION C A TUN - NAP (Fleet in Ionian Sea convoys Army from Tunis to Naples)
Army Move via Convoy: A TUN - NAP VIA (explicitly states the Army is moving from Tunis to Naples via convoy)
PARSABLE OUTPUT: 1.2. Retreat Phase (phase_type == 'R')
{ If a unit is dislodged, it must Retreat or Disband:
"summary": "...(your textual summary)..." Retreat: A BUR R PIC (Dislodged Army in Burgundy retreats to Picardy)
} Disband: A BUR D (Army in Burgundy disbands, if it cannot retreat or chooses not to)
Ensure the summary clarifies reasons for bounces, e.g., "F TRI -> VEN bounced because Italy also moved A VEN -> TRI with equal force." 1.3. Adjustment Phase (phase_type == 'A')
Build new units if you have more centers than current units:
A PAR B (Build an Army in Paris)
F MAR B (Build a Fleet in Marseilles)
Remove units if you have fewer centers than current units:
A BUR D (Disband Army in Burgundy)
Waive a build if you have a surplus but dont want/cant build:
WAIVE (no unit is built in the available build location)
No extra text outside the JSON block. 1.4. Order Types
""" H (Hold) e.g. A PAR H
- (Move) e.g. A PAR - BUR
S (Support) e.g. A MAR S A PAR - BUR or A MAR S A PAR H
C (Convoy) e.g. F ION C A TUN - NAP
R (Retreat) e.g. A BUR R PIC
D (Disband) e.g. A BUR D
B (Build) e.g. A PAR B
WAIVE skipping a possible build
1.5. Key Phase Context
Movement (M): Units can H, -, S, C.
Retreat (R): Dislodged units can only R or D.
Adjustment (A): Build/Remove units or WAIVE.
Multi-Coast: For SPA, STP, BUL, specify nc, sc, or ec when using Fleets, e.g. F BRE - SPA(sc).
Basic Validity Rules
No self-support (A PAR S A PAR - BUR is invalid).
Fleets must be on water to convoy.
Army - X VIA must have one or more fleets issuing matching C A ... - X.
"""
"Example: 'F TRI -> VEN bounced due to equal force from Italy's A VEN -> TRI'"
) )
if summary_callback: if summary_callback:
@ -4744,7 +4741,7 @@ class Game(Jsonable):
else: else:
summary_text = "(No LLM callback provided.)" summary_text = "(No LLM callback provided.)"
# 7) Store the text in the current GamePhaseData and in self.phase_summaries # Store the summary
current_phase_data.summary = summary_text current_phase_data.summary = summary_text
self.phase_summaries[str(phase_key)] = summary_text self.phase_summaries[str(phase_key)] = summary_text

View file

@ -13,7 +13,7 @@ os.environ["GRPC_PYTHON_LOG_LEVEL"] = "40" # ERROR level only
from diplomacy import Game from diplomacy import Game
from diplomacy.utils.export import to_saved_game_format from diplomacy.utils.export import to_saved_game_format
from ai_diplomacy.clients import load_model_client from ai_diplomacy.model_loader import load_model_client
from ai_diplomacy.utils import ( from ai_diplomacy.utils import (
get_valid_orders, get_valid_orders,
gather_possible_orders, gather_possible_orders,
@ -21,6 +21,7 @@ from ai_diplomacy.utils import (
) )
from ai_diplomacy.negotiations import conduct_negotiations from ai_diplomacy.negotiations import conduct_negotiations
from ai_diplomacy.game_history import GameHistory from ai_diplomacy.game_history import GameHistory
from ai_diplomacy.long_story_short import configure_context_manager
dotenv.load_dotenv() dotenv.load_dotenv()
@ -34,10 +35,10 @@ logging.basicConfig(
def my_summary_callback(system_prompt, user_prompt, model_name): def my_summary_callback(system_prompt, user_prompt, model_name):
# Route to the desired model specified by the command-line argument # Route to the desired model specified by the command-line argument
client = load_model_client(model_name) client = load_model_client(model_name, emptysystem=True)
combined_prompt = f"{system_prompt}\n\n{user_prompt}" combined_prompt = f"{system_prompt}\n\n{user_prompt}"
# Pseudo-code for generating a response: # Pseudo-code for generating a response:
return client.generate_response(combined_prompt) return client.generate_response(combined_prompt, empty_system=True)
def parse_arguments(): def parse_arguments():
@ -78,16 +79,56 @@ def parse_arguments():
), ),
) )
return parser.parse_args() return parser.parse_args()
def save_game_state(game, result_folder, game_file_path, model_error_stats, args, is_final=False):
"""
Save the current game state and related information
Args:
game: The diplomacy game instance
result_folder: Path to the results folder
game_file_path: Base path for the game file
model_error_stats: Dictionary containing model error statistics
args: Command line arguments
is_final: Boolean indicating if this is the final save
"""
# Generate unique filename for periodic saves
timestamp = time.strftime("%Y%m%d_%H%M%S")
if not is_final:
output_path = f"{game_file_path}_checkpoint_{timestamp}.json"
else:
output_path = game_file_path
# If final file exists, append timestamp
if os.path.exists(output_path):
logger.info("Game file already exists, saving with unique filename.")
output_path = f"{output_path}_{timestamp}.json"
# Save game state
to_saved_game_format(game, output_path=output_path)
# Save overview data
overview_file_path = f"{result_folder}/overview.jsonl"
with open(overview_file_path, "w") as overview_file:
overview_file.write(json.dumps(model_error_stats) + "\n")
overview_file.write(json.dumps(game.power_model_map) + "\n")
overview_file.write(json.dumps(vars(args)) + "\n")
logger.info(f"Saved game checkpoint to: {output_path}")
def main(): def main():
args = parse_arguments() args = parse_arguments()
# Configure the context manager with the same summary model
configure_context_manager(
phase_threshold=10000,
message_threshold=10000,
summary_model=args.summary_model
)
max_year = args.max_year max_year = args.max_year
summary_model = args.summary_model summary_model = args.summary_model
logger.info( logger.info("Starting a new Diplomacy game for testing with multiple LLMs, now concurrent!")
"Starting a new Diplomacy game for testing with multiple LLMs, now concurrent!"
)
start_whole = time.time() start_whole = time.time()
model_error_stats = defaultdict( model_error_stats = defaultdict(
@ -107,6 +148,18 @@ def main():
result_folder = f"./results/{timestamp_str}" result_folder = f"./results/{timestamp_str}"
os.makedirs(result_folder, exist_ok=True) os.makedirs(result_folder, exist_ok=True)
# ---------------------------
# ADD FILE HANDLER FOR LOGS
# ---------------------------
log_file_path = os.path.join(result_folder, "game.log")
file_handler = logging.FileHandler(log_file_path)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(
logging.Formatter("%(asctime)s [%(levelname)s] %(name)s - %(message)s", datefmt="%H:%M:%S")
)
logger.addHandler(file_handler)
logger.info(f"File handler added. Writing logs to {log_file_path}.")
# File paths # File paths
manifesto_path = f"{result_folder}/game_manifesto.txt" manifesto_path = f"{result_folder}/game_manifesto.txt"
# Use provided output filename or generate one based on the timestamp # Use provided output filename or generate one based on the timestamp
@ -133,7 +186,21 @@ def main():
return return
game.power_model_map = dict(zip(powers_order, provided_models)) game.power_model_map = dict(zip(powers_order, provided_models))
else: else:
game.power_model_map = assign_models_to_powers() game.power_model_map = assign_models_to_powers(randomize=True)
logger.debug("Power model assignments:")
for power, model_id in game.power_model_map.items():
logger.debug(f"{power} => type={type(model_id)}, value={model_id}")
# Also, if you prefer to fix the negotiation function:
# We could do a one-liner ensuring all model_id are strings:
for p in game.power_model_map:
if not isinstance(game.power_model_map[p], str):
game.power_model_map[p] = str(game.power_model_map[p])
logger.info("Post-cleanup: Verified all power model IDs are strings.")
round_counter = 0 # Track number of rounds
while not game.is_game_done: while not game.is_game_done:
phase_start = time.time() phase_start = time.time()
@ -143,7 +210,7 @@ def main():
) )
# DEBUG: Print the short phase to confirm # DEBUG: Print the short phase to confirm
logger.info(f"DEBUG: current_short_phase is '{game.current_short_phase}'") logger.info(f"INFO: The current short phase is '{game.current_short_phase}'")
# Prevent unbounded simulation based on year # Prevent unbounded simulation based on year
year_str = current_phase[1:5] year_str = current_phase[1:5]
@ -253,6 +320,14 @@ def main():
with open(manifesto_path, "a") as f: with open(manifesto_path, "a") as f:
f.write(f"=== {phase_data.name} ===\n{summary_text}\n\n") f.write(f"=== {phase_data.name} ===\n{summary_text}\n\n")
# Increment round counter after processing each phase
round_counter += 1
# Save every 5 rounds
if round_counter % 5 == 0:
logger.info(f"Saving checkpoint after round {round_counter}...")
save_game_state(game, result_folder, game_file_path, model_error_stats, args, is_final=False)
# Check if we've exceeded the max year # Check if we've exceeded the max year
year_str = current_phase[1:5] year_str = current_phase[1:5]
year_int = int(year_str) year_int = int(year_str)
@ -262,21 +337,10 @@ def main():
# Save final result # Save final result
duration = time.time() - start_whole duration = time.time() - start_whole
logger.info(f"Game ended after {duration:.2f}s. Saving to final JSON...") logger.info(f"Game ended after {duration:.2f}s. Saving final state...")
output_path = game_file_path save_game_state(game, result_folder, game_file_path, model_error_stats, args, is_final=True)
# If the file already exists, append a timestamp to the filename
if os.path.exists(output_path):
logger.info("Game file already exists, saving with unique filename.")
output_path = f"{output_path}_{time.strftime('%Y%m%d_%H%M%S')}.json"
to_saved_game_format(game, output_path=output_path)
# Dump error stats and power model mapping to the overview file
with open(overview_file_path, "w") as overview_file:
overview_file.write(json.dumps(model_error_stats) + "\n")
overview_file.write(json.dumps(game.power_model_map) + "\n")
overview_file.write(json.dumps(vars(args)) + "\n")
logger.info(f"Saved game data, manifesto, and error stats in: {result_folder}") logger.info(f"Saved game data, manifesto, and error stats in: {result_folder}")
logger.info("Done.") logger.info("Done.")

View file

@ -87,7 +87,7 @@
"\n", "\n",
"# Plot unit counts per country\n", "# Plot unit counts per country\n",
"for country in countries:\n", "for country in countries:\n",
" axs[0].plot(turns, unit_counts[country], label=model_map[country])\n", " axs[0].plot(turns, unit_counts[country], label=f\"{model_map[country]} ({country})\")\n",
"axs[0].set_title(\"Unit Counts per Country Over Turns\")\n", "axs[0].set_title(\"Unit Counts per Country Over Turns\")\n",
"axs[0].set_ylabel(\"Number of Units\")\n", "axs[0].set_ylabel(\"Number of Units\")\n",
"axs[0].set_xlabel(\"Turns\")\n", "axs[0].set_xlabel(\"Turns\")\n",
@ -97,7 +97,7 @@
"\n", "\n",
"# Plot center counts per country\n", "# Plot center counts per country\n",
"for country in countries:\n", "for country in countries:\n",
" axs[1].plot(turns, center_counts[country], label=model_map[country])\n", " axs[1].plot(turns, center_counts[country], label=f\"{model_map[country]} ({country})\")\n",
"axs[1].set_title(\"Center Counts per Country Over Turns\")\n", "axs[1].set_title(\"Center Counts per Country Over Turns\")\n",
"axs[1].set_ylabel(\"Number of Centers\")\n", "axs[1].set_ylabel(\"Number of Centers\")\n",
"axs[1].set_xlabel(\"Turns\")\n", "axs[1].set_xlabel(\"Turns\")\n",