add simplified prompts

This commit is contained in:
sam-paech 2025-06-27 14:42:05 +10:00
parent 0bd909b30b
commit ebf26cf8a6
33 changed files with 1762 additions and 143 deletions

View file

@ -90,26 +90,24 @@ class DiplomacyAgent:
# --- Load and set the appropriate system prompt ---
# Get the directory containing the current file (agent.py)
current_dir = os.path.dirname(os.path.abspath(__file__))
# Construct path relative to the current file's directory
default_prompts_path = os.path.join(current_dir, "prompts")
power_prompt_filename = f"{power_name.lower()}_system_prompt.txt"
default_prompt_filename = "system_prompt.txt"
current_dir = os.path.dirname(os.path.abspath(__file__))
default_prompts_path = os.path.join(current_dir, "prompts")
prompts_root = self.prompts_dir or default_prompts_path
# Use the provided prompts_dir if available, otherwise use the default
prompts_path_to_use = self.prompts_dir if self.prompts_dir else default_prompts_path
power_prompt_filepath = os.path.join(prompts_path_to_use, power_prompt_filename)
default_prompt_filepath = os.path.join(prompts_path_to_use, default_prompt_filename)
power_prompt_name = f"{power_name.lower()}_system_prompt.txt"
default_prompt_name = "system_prompt.txt"
system_prompt_content = load_prompt(power_prompt_filepath, prompts_dir=self.prompts_dir)
power_prompt_path = os.path.join(prompts_root, power_prompt_name)
default_prompt_path = os.path.join(prompts_root, default_prompt_name)
system_prompt_content = load_prompt(power_prompt_path)
if not system_prompt_content:
logger.warning(f"Power-specific prompt '{power_prompt_filepath}' not found or empty. Loading default system prompt.")
system_prompt_content = load_prompt(default_prompt_filepath, prompts_dir=self.prompts_dir)
else:
logger.info(f"Loaded power-specific system prompt for {power_name}.")
# ----------------------------------------------------
logger.warning(
f"Power-specific prompt not found at {power_prompt_path}. Falling back to default."
)
system_prompt_content = load_prompt(default_prompt_path)
if system_prompt_content: # Ensure we actually have content before setting
self.client.set_system_prompt(system_prompt_content)
@ -547,6 +545,12 @@ class DiplomacyAgent:
diary_text_candidate = parsed_data[key].strip()
logger.info(f"[{self.power_name}] Successfully extracted '{key}' for diary.")
break
if 'intent' in parsed_data:
if diary_text_candidate == None:
diary_text_candidate = parsed_data['intent']
else:
diary_text_candidate += '\nIntent: ' + parsed_data['intent']
if diary_text_candidate:
diary_entry_text = diary_text_candidate

View file

@ -32,7 +32,7 @@ async def run_diary_consolidation(
agent: 'DiplomacyAgent',
game: "Game",
log_file_path: str,
entries_to_keep_unsummarized: int = 15,
entries_to_keep_unsummarized: int = 6,
prompts_dir: Optional[str] = None,
):
"""

View file

@ -179,7 +179,8 @@ def save_game_state(
def load_game_state(
run_dir: str,
game_file_name: str,
resume_from_phase: Optional[str] = None
run_config: Namespace,
resume_from_phase: Optional[str] = None,
) -> Tuple[Game, Dict[str, DiplomacyAgent], GameHistory, Optional[Namespace]]:
"""Loads and reconstructs the game state from a saved game file."""
game_file_path = os.path.join(run_dir, game_file_name)
@ -190,15 +191,6 @@ def load_game_state(
with open(game_file_path, 'r') as f:
saved_game_data = json.load(f)
# Find the latest config saved in the file
run_config = None
if saved_game_data.get("phases"):
for phase in reversed(saved_game_data["phases"]):
if "config" in phase:
run_config = Namespace(**phase["config"])
logger.info(f"Loaded run configuration from phase {phase['name']}.")
break
# If resuming, find the specified phase and truncate the data after it
if resume_from_phase:
logger.info(f"Resuming from phase '{resume_from_phase}'. Truncating subsequent data.")

View file

@ -5,6 +5,7 @@ from typing import Dict, List, Callable, Optional, Any, Set, Tuple
from diplomacy.engine.map import Map as GameMap
from diplomacy.engine.game import Game as BoardState
import logging
import re
# Placeholder for actual map type from diplomacy.engine.map.Map
# GameMap = Any
@ -15,78 +16,61 @@ logger = logging.getLogger(__name__)
def build_diplomacy_graph(game_map: GameMap) -> Dict[str, Dict[str, List[str]]]:
"""
Builds a graph where keys are SHORT province names (e.g., 'PAR', 'STP').
Adjacency lists also contain SHORT province names.
This graph is used for BFS pathfinding.
Return graph[PROV]['ARMY'|'FLEET'] = list of 3-letter neighbour provinces.
Works for dual-coast provinces by interrogating `abuts()` directly instead
of relying on loc_abut.
"""
graph: Dict[str, Dict[str, List[str]]] = {}
# Deriving a clean list of unique, 3-letter, uppercase short province names
# game_map.locs contains all locations, including coasts e.g. "STP/SC"
unique_short_names = set()
for loc in game_map.locs:
short_name = loc.split('/')[0][:3].upper() # Take first 3 chars and uppercase
if len(short_name) == 3: # Ensure it's a 3-letter name
unique_short_names.add(short_name)
all_short_province_names = sorted(list(unique_short_names))
# ── collect all 3-letter province codes ───────────────────────────────
provs: Set[str] = {
loc.split("/")[0][:3].upper() # 'BUL/EC' -> 'BUL'
for loc in game_map.locs
if len(loc.split("/")[0]) == 3
}
# Initialize graph with all valid short province names as keys
for province_name in all_short_province_names:
graph[province_name] = {'ARMY': [], 'FLEET': []}
graph: Dict[str, Dict[str, List[str]]] = {
p: {"ARMY": [], "FLEET": []} for p in provs
}
for province_short_source in all_short_province_names: # e.g. 'PAR', 'STP'
# Get all full names for this source province (e.g. 'STP' -> ['STP/NC', 'STP/SC', 'STP'])
full_names_for_source = game_map.loc_coasts.get(province_short_source, [province_short_source])
# ── helper: list every concrete variant of a province ─────────────────
def variants(code: str) -> List[str]:
lst = list(game_map.loc_coasts.get(code, []))
if code not in lst:
lst.append(code) # ensure base node included
return lst
for loc_full_source_variant in full_names_for_source: # e.g. 'STP/NC', then 'STP/SC', then 'STP'
# province_short_source is already the short name like 'STP'
# game_map.loc_abut provides general adjacencies, which might include specific coasts or lowercase names
for raw_adj_loc_from_loc_abut in game_map.loc_abut.get(province_short_source, []):
# Normalize this raw adjacent location to its short, uppercase form
adj_short_name_normalized = raw_adj_loc_from_loc_abut[:3].upper()
# ── populate adjacency by brute-force queries to `abuts()` ────────────
for src in provs:
src_vers = variants(src)
# Get all full names for this *normalized* adjacent short name (e.g. 'BUL' -> ['BUL/EC', 'BUL/SC', 'BUL'])
full_names_for_adj_dest = game_map.loc_coasts.get(adj_short_name_normalized, [adj_short_name_normalized])
for dest in provs:
if dest == src:
continue
dest_vers = variants(dest)
# Check for ARMY movement
unit_char_army = 'A'
if any(
game_map.abuts(
unit_char_army,
loc_full_source_variant, # Specific full source, e.g. 'STP/NC'
'-', # Order type for move
full_dest_variant # Specific full destination, e.g. 'MOS' or 'FIN'
)
for full_dest_variant in full_names_for_adj_dest
):
if adj_short_name_normalized not in graph[province_short_source]['ARMY']:
graph[province_short_source]['ARMY'].append(adj_short_name_normalized)
# ARMYonly bases count as the origin (armies cant sit on /EC)
if any(
game_map.abuts("A", src, "-", dv) # src is the base node
for dv in dest_vers
):
graph[src]["ARMY"].append(dest)
# FLEETany src variant that can host a fleet is valid
if any(
game_map.abuts("F", sv, "-", dv)
for sv in src_vers
for dv in dest_vers
):
graph[src]["FLEET"].append(dest)
# ── tidy up duplicates / order ---------------------------------------
for p in graph:
graph[p]["ARMY"] = sorted(set(graph[p]["ARMY"]))
graph[p]["FLEET"] = sorted(set(graph[p]["FLEET"]))
# Check for FLEET movement
unit_char_fleet = 'F'
if any(
game_map.abuts(
unit_char_fleet,
loc_full_source_variant, # Specific full source, e.g. 'STP/NC'
'-', # Order type for move
full_dest_variant # Specific full destination, e.g. 'BAR' or 'NWY'
)
for full_dest_variant in full_names_for_adj_dest
):
if adj_short_name_normalized not in graph[province_short_source]['FLEET']:
graph[province_short_source]['FLEET'].append(adj_short_name_normalized)
# Remove duplicates from adjacency lists (just in case)
for province_short in graph:
if 'ARMY' in graph[province_short]:
graph[province_short]['ARMY'] = sorted(list(set(graph[province_short]['ARMY'])))
if 'FLEET' in graph[province_short]:
graph[province_short]['FLEET'] = sorted(list(set(graph[province_short]['FLEET'])))
return graph
def bfs_shortest_path(
graph: Dict[str, Dict[str, List[str]]],
board_state: BoardState,
@ -241,33 +225,56 @@ def get_nearest_enemy_units(
def get_nearest_uncontrolled_scs(
game_map: GameMap,
board_state: BoardState,
graph: Dict[str, Dict[str, List[str]]],
power_name: str,
start_unit_loc_full: str,
start_unit_type: str,
n: int = 3
) -> List[Tuple[str, int, List[str]]]: # (sc_name_short, distance, path_short_names)
"""Finds up to N nearest SCs not controlled by power_name, sorted by path length."""
uncontrolled_sc_paths: List[Tuple[str, int, List[str]]] = []
game_map: GameMap,
board_state: BoardState,
graph: Dict[str, Dict[str, List[str]]],
power_name: str,
start_unit_loc_full: str,
start_unit_type: str,
n: int = 3,
) -> List[Tuple[str, int, List[str]]]:
"""
Return up to N nearest supply centres not controlled by `power_name`,
excluding centres that are the units own province (distance 0) or
adjacent in one move (distance 1).
all_scs_short = game_map.scs # This is a list of short province names that are SCs
Each tuple is (sc_code + ctrl_tag, distance, path_of_short_codes).
"""
results: List[Tuple[str, int, List[str]]] = []
for sc_short in game_map.scs: # all SC province codes
controller = get_sc_controller(game_map, board_state, sc_short)
if controller == power_name:
continue # already ours
# helper for BFS target test
def is_target(loc_short: str, _state: BoardState) -> bool:
return loc_short == sc_short
path = bfs_shortest_path(
graph,
board_state,
game_map,
start_unit_loc_full,
start_unit_type,
is_target,
)
if not path:
continue # unreachable
distance = len(path) - 1 # moves needed
# skip distance 0 (same province) and 1 (adjacent)
if distance <= 1:
continue
tag = f"{sc_short} (Ctrl: {controller or 'None'})"
results.append((tag, distance, path))
# sort by distance, then SC code for tie-breaks
results.sort(key=lambda x: (x[1], x[0]))
return results[:n]
for sc_loc_short in all_scs_short:
controller = get_sc_controller(game_map, board_state, sc_loc_short)
if controller != power_name:
def is_target_sc(loc_short: str, current_board_state: BoardState) -> bool:
return loc_short == sc_loc_short
path_short_names = bfs_shortest_path(graph, board_state, game_map, start_unit_loc_full, start_unit_type, is_target_sc)
if path_short_names:
# Path includes start, so distance is len - 1
uncontrolled_sc_paths.append((f"{sc_loc_short} (Ctrl: {controller or 'None'})", len(path_short_names) -1, path_short_names))
# Sort by distance (path length - 1), then by SC name for tie-breaking
uncontrolled_sc_paths.sort(key=lambda x: (x[1], x[0]))
return uncontrolled_sc_paths[:n]
def get_adjacent_territory_details(
game_map: GameMap,
@ -362,7 +369,7 @@ def get_adjacent_territory_details(
# --- Main context generation function ---
def generate_rich_order_context(game: Any, power_name: str, possible_orders_for_power: Dict[str, List[str]]) -> str:
def generate_rich_order_context_xml(game: Any, power_name: str, possible_orders_for_power: Dict[str, List[str]]) -> str:
"""
Generates a strategic overview context string.
Details units and SCs for power_name, including possible orders and simplified adjacencies for its units.
@ -456,3 +463,405 @@ def generate_rich_order_context(game: Any, power_name: str, possible_orders_for_
final_context_lines.append("</PossibleOrdersContext>")
return "\n".join(final_context_lines)
# ---------------------------------------------------------------------------
# Regex and tiny helpers
# ---------------------------------------------------------------------------
import re
from typing import Tuple, List, Dict, Optional, Any
# ── order-syntax matchers ─────────────────────────────────────────────────
_SIMPLE_MOVE_RE = re.compile(r"^[AF] [A-Z]{3}(?:/[A-Z]{2})? - [A-Z]{3}(?:/[A-Z]{2})?$")
_HOLD_RE = re.compile(r"^[AF] [A-Z]{3}(?:/[A-Z]{2})? H$") # NEW
_RETREAT_RE = re.compile(r"^[AF] [A-Z]{3}(?:/[A-Z]{2})? R [A-Z]{3}(?:/[A-Z]{2})?$")
_ADJUST_RE = re.compile(r"^[AF] [A-Z]{3}(?:/[A-Z]{2})? [BD]$") # build / disband
def _is_hold_order(order: str) -> bool: # NEW
return bool(_HOLD_RE.match(order.strip()))
def _norm_power(name: str) -> str:
"""Trim & uppercase for reliable comparisons."""
return name.strip().upper()
def _is_simple_move(order: str) -> bool:
return bool(_SIMPLE_MOVE_RE.match(order.strip()))
def _is_retreat_order(order: str) -> bool:
return bool(_RETREAT_RE.match(order.strip()))
def _is_adjust_order(order: str) -> bool:
return bool(_ADJUST_RE.match(order.strip()))
def _split_move(order: str) -> Tuple[str, str]:
"""Return ('A BUD', 'TRI') from 'A BUD - TRI' (validated move only)."""
unit_part, dest = order.split(" - ")
return unit_part.strip(), dest.strip()
# ---------------------------------------------------------------------------
# Gather *all* friendly support orders for a given move
# ---------------------------------------------------------------------------
def _all_support_examples(
mover: str,
dest: str,
all_orders: Dict[str, List[str]],
) -> List[str]:
"""
Return *every* order of the form 'A/F XYZ S <mover> - <dest>'
issued by our other units. Order of return is input order.
"""
target = f"{mover} - {dest}"
supports: List[str] = []
for loc, orders in all_orders.items():
if mover.endswith(loc):
continue # skip the moving unit itself
for o in orders:
if " S " in o and target in o:
supports.append(o.strip())
return supports
def _all_support_hold_examples(
holder: str,
all_orders: Dict[str, List[str]],
) -> List[str]:
"""
Return every order of the form 'A/F XYZ S <holder>' that supports
<holder> to HOLD, excluding the holding unit itself.
"""
target = f" S {holder}"
supports: List[str] = []
for loc, orders in all_orders.items():
if holder.endswith(loc): # skip the holding unit
continue
for o in orders:
if o.strip().endswith(target):
supports.append(o.strip())
return supports
# ---------------------------------------------------------------------------
# Province-type resolver (handles short codes, coasts, seas)
# ---------------------------------------------------------------------------
def _province_type_display(game_map, prov_short: str) -> str:
"""
Return 'LAND', 'COAST', or 'WATER' for the 3-letter province code.
Falls back to 'UNKNOWN' only if nothing matches.
"""
for full in game_map.loc_coasts.get(prov_short, [prov_short]):
t = game_map.loc_type.get(full)
if not t:
continue
t = t.upper()
if t in ("LAND", "L"):
return "LAND"
if t in ("COAST", "C"):
return "COAST"
if t in ("WATER", "SEA", "W"):
return "WATER"
return "UNKNOWN"
def _dest_occupancy_desc(
dest_short: str,
game_map,
board_state,
our_power: str,
) -> str:
""" '(occupied by X)', '(occupied by X — you!)', or '(unoccupied)' """
occupant: Optional[str] = None
for full in game_map.loc_coasts.get(dest_short, [dest_short]):
u = get_unit_at_location(board_state, full)
if u:
occupant = u.split(" ")[-1].strip("()")
break
if occupant is None:
return "(unoccupied)"
if occupant == our_power:
return f"(occupied by {occupant} — you!)"
return f"(occupied by {occupant})"
# ---------------------------------------------------------------------------
# Adjacent-territory lines (used by movement-phase builder)
# ---------------------------------------------------------------------------
def _adjacent_territory_lines(
graph,
game_map,
board_state,
unit_loc_full: str,
mover_descr: str,
our_power: str,
) -> List[str]:
lines: List[str] = []
indent1 = " "
indent2 = " "
unit_loc_short = game_map.loc_name.get(unit_loc_full, unit_loc_full)[:3]
mover_type_key = "ARMY" if mover_descr.startswith("A") else "FLEET"
adjacents = graph.get(unit_loc_short, {}).get(mover_type_key, [])
for adj in adjacents:
typ_display = _province_type_display(game_map, adj)
base_parts = [f"{indent1}{adj} ({typ_display})"]
sc_ctrl = get_sc_controller(game_map, board_state, adj)
if sc_ctrl:
base_parts.append(f"SC Control: {sc_ctrl}")
unit_here = None
for full in game_map.loc_coasts.get(adj, [adj]):
unit_here = get_unit_at_location(board_state, full)
if unit_here:
break
if unit_here:
base_parts.append(f"Units: {unit_here}")
lines.append(" ".join(base_parts))
# second analytical line if occupied
if unit_here:
pwr = unit_here.split(" ")[-1].strip("()")
if pwr == our_power:
friend_descr = unit_here.split(" (")[0]
lines.append(
f"{indent2}Support hold: {mover_descr} S {friend_descr}"
)
else:
lines.append(
f"{indent2}-> {unit_here} can support or contest {mover_descr}s moves and vice-versa"
)
return lines
# ---------------------------------------------------------------------------
# Movement-phase generator (UNCHANGED LOGIC)
# ---------------------------------------------------------------------------
def _generate_rich_order_context_movement(
game: Any,
power_name: str,
possible_orders_for_power: Dict[str, List[str]],
) -> str:
"""
Produce the <Territory > blocks *exactly* as before for movement phases.
"""
board_state = game.get_state()
game_map = game.map
graph = build_diplomacy_graph(game_map)
blocks: List[str] = []
me = _norm_power(power_name)
for unit_loc_full, orders in possible_orders_for_power.items():
unit_full_str = get_unit_at_location(board_state, unit_loc_full)
if not unit_full_str:
continue
unit_power = unit_full_str.split(" ")[-1].strip("()")
if _norm_power(unit_power) != me:
continue # Skip units that arent ours
mover_descr, _ = _split_move(
f"{unit_full_str.split(' ')[0]} {unit_loc_full} - {unit_loc_full}"
)
prov_short = game_map.loc_name.get(unit_loc_full, unit_loc_full)[:3]
prov_type_disp = _province_type_display(game_map, prov_short)
sc_tag = " (SC)" if prov_short in game_map.scs else ""
owner = get_sc_controller(game_map, board_state, unit_loc_full) or "None"
owner_line = (
f"Held by {owner} (You)" if owner == power_name else f"Held by {owner}"
)
ind = " "
block: List[str] = [f"<Territory {prov_short}>"]
block.append(f"{ind}({prov_type_disp}){sc_tag}")
block.append(f"{ind}{owner_line}")
block.append(f"{ind}Units present: {unit_full_str}")
# ----- Adjacent territories -----
block.append("# Adjacent territories:")
block.extend(
_adjacent_territory_lines(
graph, game_map, board_state,
unit_loc_full, mover_descr, power_name
)
)
# ----- Nearest enemy units -----
block.append("# Nearest units (not ours):")
enemies = get_nearest_enemy_units(
board_state, graph, game_map,
power_name, unit_loc_full,
"ARMY" if mover_descr.startswith("A") else "FLEET",
n=3,
)
for u, path in enemies:
path_disp = "".join([unit_loc_full] + path[1:])
block.append(f"{ind}{u}, path [{path_disp}]")
# ----- Nearest uncontrolled SCs -----
block.append("# Nearest supply centers (not controlled by us):")
scs = get_nearest_uncontrolled_scs(
game_map, board_state, graph,
power_name, unit_loc_full,
"ARMY" if mover_descr.startswith("A") else "FLEET",
n=3,
)
for sc_str, dist, sc_path in scs:
path_disp = "".join([unit_loc_full] + sc_path[1:])
sc_fmt = sc_str.replace("Ctrl:", "Controlled by")
block.append(f"{ind}{sc_fmt}, path [{path_disp}]")
# ----- Possible moves -----
block.append(f"# Possible {mover_descr} unit movements & supports:")
simple_moves = [o for o in orders if _is_simple_move(o)]
hold_orders = [o for o in orders if _is_hold_order(o)] # NEW
if not simple_moves and not hold_orders:
block.append(f"{ind}None")
else:
# ---- Moves (same behaviour as before) ----
for mv in simple_moves:
mover, dest = _split_move(mv)
occ = _dest_occupancy_desc(
dest.split("/")[0][:3], game_map, board_state, power_name
)
block.append(f"{ind}{mv} {occ}")
for s in _all_support_examples(mover, dest, possible_orders_for_power):
block.append(f"{ind*2}Available Support: {s}")
# ---- Holds (new) ----
for hd in hold_orders:
holder = hd.split(" H")[0] # e.g., 'F DEN'
block.append(f"{ind}{hd}")
for s in _all_support_hold_examples(holder, possible_orders_for_power):
block.append(f"{ind*2}Available Support: {s}")
block.append(f"</Territory {prov_short}>")
blocks.append("\n".join(block))
return "\n\n".join(blocks)
# ---------------------------------------------------------------------------
# Retreat-phase builder echo orders verbatim, no tags
# ---------------------------------------------------------------------------
def _generate_rich_order_context_retreat(
game: Any,
power_name: str,
possible_orders_for_power: Dict[str, List[str]],
) -> str:
"""
Flatten all retreat / disband orders into one list:
A PAR R PIC
A PAR D
F NTH R HEL
If the engine supplies nothing, return the standard placeholder.
"""
lines: List[str] = []
for orders in possible_orders_for_power.values():
for o in orders:
lines.append(o.strip())
return "\n".join(lines) if lines else "(No dislodged units)"
# ---------------------------------------------------------------------------
# Adjustment-phase builder summary line + orders, no WAIVEs, no tags
# ---------------------------------------------------------------------------
def _generate_rich_order_context_adjustment(
game: Any,
power_name: str,
possible_orders_for_power: Dict[str, List[str]],
) -> str:
"""
* First line states how many builds are allowed or disbands required.
* Echo every B/D order exactly as supplied, skipping WAIVE.
* No wrapper tags.
"""
board_state = game.get_state()
sc_owned = len(board_state.get("centers", {}).get(power_name, []))
units_num = len(board_state.get("units", {}).get(power_name, []))
delta = sc_owned - units_num # +ve ⇒ builds, -ve ⇒ disbands
# ----- summary line ----------------------------------------------------
if delta > 0:
summary = f"Builds available: {delta}"
elif delta < 0:
summary = f"Disbands required: {-delta}"
else:
summary = "No builds or disbands required"
# ----- collect orders (skip WAIVE) -------------------------------------
lines: List[str] = [summary]
for orders in possible_orders_for_power.values():
for o in orders:
if "WAIVE" in o.upper():
continue
lines.append(o.strip())
# If nothing but the summary, just return the summary.
return "\n".join(lines) if len(lines) > 1 else summary
# ---------------------------------------------------------------------------
# Phase-dispatch wrapper (public entry point)
# ---------------------------------------------------------------------------
def generate_rich_order_context(
game: Any,
power_name: str,
possible_orders_for_power: Dict[str, List[str]],
) -> str:
"""
Call the correct phase-specific builder.
* Movement phase output is IDENTICAL to the previous implementation.
* Retreat and Adjustment phases use the streamlined builders introduced
earlier.
"""
phase_type = game.current_short_phase[-1]
if phase_type == "M": # Movement
return _generate_rich_order_context_movement(
game, power_name, possible_orders_for_power
)
if phase_type == "R": # Retreat
return _generate_rich_order_context_retreat(
game, power_name, possible_orders_for_power
)
if phase_type == "A": # Adjustment (build / disband)
return _generate_rich_order_context_adjustment(
game, power_name, possible_orders_for_power
)
# Fallback treat unknown formats as movement
return _generate_rich_order_context_movement(
game, power_name, possible_orders_for_power
)

View file

@ -5,7 +5,11 @@ import logging
from typing import Dict, List, Optional, Any # Added Any for game type placeholder
from .utils import load_prompt
from .possible_order_context import generate_rich_order_context
from .possible_order_context import (
generate_rich_order_context,
generate_rich_order_context_xml,
)
import os
from .game_history import GameHistory # Assuming GameHistory is correctly importable
# placeholder for diplomacy.Game to avoid circular or direct dependency if not needed for typehinting only
@ -14,6 +18,17 @@ from .game_history import GameHistory # Assuming GameHistory is correctly import
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG) # Or inherit from parent logger
# --- Home-center lookup -------------------------------------------
HOME_CENTERS: dict[str, list[str]] = {
"AUSTRIA": ["Budapest", "Trieste", "Vienna"],
"ENGLAND": ["Edinburgh", "Liverpool", "London"],
"FRANCE": ["Brest", "Marseilles", "Paris"],
"GERMANY": ["Berlin", "Kiel", "Munich"],
"ITALY": ["Naples", "Rome", "Venice"],
"RUSSIA": ["Moscow", "Saint Petersburg", "Sevastopol", "Warsaw"],
"TURKEY": ["Ankara", "Constantinople", "Smyrna"],
}
def build_context_prompt(
game: Any, # diplomacy.Game object
board_state: dict,
@ -24,6 +39,7 @@ def build_context_prompt(
agent_relationships: Optional[Dict[str, str]] = None,
agent_private_diary: Optional[str] = None,
prompts_dir: Optional[str] = None,
include_messages: Optional[bool] = True,
) -> str:
"""Builds the detailed context part of the prompt.
@ -59,14 +75,27 @@ def build_context_prompt(
# Get the current phase
year_phase = board_state["phase"] # e.g. 'S1901M'
possible_orders_context_str = generate_rich_order_context(game, power_name, possible_orders)
# Decide which context builder to use.
_use_simple = os.getenv("SIMPLE_PROMPTS", "0").lower() in {"1", "true", "yes"}
if _use_simple:
possible_orders_context_str = generate_rich_order_context(
game, power_name, possible_orders
)
else:
possible_orders_context_str = generate_rich_order_context_xml(
game, power_name, possible_orders
)
messages_this_round_text = game_history.get_messages_this_round(
power_name=power_name,
current_phase_name=year_phase
)
if not messages_this_round_text.strip():
messages_this_round_text = "\n(No messages this round)\n"
if include_messages:
messages_this_round_text = game_history.get_messages_this_round(
power_name=power_name,
current_phase_name=year_phase
)
if not messages_this_round_text.strip():
messages_this_round_text = "\n(No messages this round)\n"
else:
messages_this_round_text = "\n"
# Separate active and eliminated powers for clarity
active_powers = [p for p in game.powers.keys() if not game.powers[p].is_eliminated()]
@ -75,21 +104,30 @@ def build_context_prompt(
# Build units representation with power status
units_lines = []
for p, u in board_state["units"].items():
u_str = ", ".join(u)
if game.powers[p].is_eliminated():
units_lines.append(f" {p}: {u} [ELIMINATED]")
units_lines.append(f" {p}: {u_str} [ELIMINATED]")
else:
units_lines.append(f" {p}: {u}")
units_lines.append(f" {p}: {u_str}")
units_repr = "\n".join(units_lines)
# Build centers representation with power status
centers_lines = []
for p, c in board_state["centers"].items():
c_str = ", ".join(c)
if game.powers[p].is_eliminated():
centers_lines.append(f" {p}: {c} [ELIMINATED]")
centers_lines.append(f" {p}: {c_str} [ELIMINATED]")
else:
centers_lines.append(f" {p}: {c}")
centers_lines.append(f" {p}: {c_str}")
centers_repr = "\n".join(centers_lines)
# Build {home_centers}
home_centers_str = ", ".join(HOME_CENTERS.get(power_name.upper(), []))
# Replace token only if it exists (template may not include it)
if "{home_centers}" in context_template:
context_template = context_template.replace("{home_centers}", home_centers_str)
context = context_template.format(
power_name=power_name,
current_phase=year_phase,
@ -135,7 +173,19 @@ def construct_order_generation_prompt(
"""
# Load prompts
_ = load_prompt("few_shot_example.txt", prompts_dir=prompts_dir) # Loaded but not used, as per original logic
instructions = load_prompt("order_instructions.txt", prompts_dir=prompts_dir)
# Pick the phase-specific instruction file
phase_code = board_state["phase"][-1] # 'M' (movement), 'R', or 'A' / 'B'
if phase_code == "M":
instructions_file = "order_instructions_movement_phase.txt"
elif phase_code in ("A", "B"): # builds / adjustments
instructions_file = "order_instructions_adjustment_phase.txt"
elif phase_code == "R": # retreats
instructions_file = "order_instructions_retreat_phase.txt"
else: # unexpected default to movement rules
instructions_file = "order_instructions_movement_phase.txt"
instructions = load_prompt(instructions_file, prompts_dir=prompts_dir)
_use_simple = os.getenv("SIMPLE_PROMPTS", "0").lower() in {"1", "true", "yes"}
# Build the context prompt
context = build_context_prompt(
@ -148,7 +198,9 @@ def construct_order_generation_prompt(
agent_relationships=agent_relationships,
agent_private_diary=agent_private_diary_str,
prompts_dir=prompts_dir,
include_messages=not _use_simple, # include only when *not* simple
)
final_prompt = system_prompt + "\n\n" + context + "\n\n" + instructions
return final_prompt
return final_prompt

View file

@ -0,0 +1,122 @@
PRIMARY OBJECTIVE
Control 18 supply centers. Nothing else will do.
CRITICAL RULES
1. Only use orders from the provided possible_orders list
2. Support orders must match actual moves (e.g., 'A PAR S F PIC - ENG' needs 'F PIC - ENG')
3. Build orders (build phase only):
- Format: '[UnitType] [Location3LetterCode] B'
- UnitType: 'A' (Army) or 'F' (Fleet)
- Example: 'A PAR B', 'F LON B'
- NOTE YOU CAN ONLY BUILD UNITS IN YOUR HOME CENTER THAT ARE EMPTY, THE ONES YOU STARTED WITH, IF YOU LOSE THESE YOU CANNOT BUILD UNITS SO THEY ARE CRITICAL
Austria
- Budapest
- Trieste
- Vienna
England
- Edinburgh
- Liverpool
- London
France
- Brest
- Marseilles
- Paris
Germany
- Berlin
- Kiel
- Munich
Italy
- Naples
- Rome
- Venice
Russia
- Moscow
- Saint Petersburg
- Sevastopol
- Warsaw
Turkey
- Ankara
- Constantinople
- Smyrna
ORDER SUBMISSION PROCESS
1. ANALYZE
- Review game state, orders, messages, and other powers' motivations
- Focus on expansion and capturing supply centers
- Be aggressive, not passive
- Take calculated risks for significant gains
- Find alternative paths if blocked
2. REASON
- Write out your strategic thinking
- Explain goals and move choices
- Consider supports and holds
3. FORMAT
Return orders in this exact format:
PARSABLE OUTPUT:
{{
"orders": ["order1", "order2", ...]
}}
4. Dual-coast provinces (STP, SPA, BUL):
- Specify coast when needed: 'F [PROVINCE]/[COAST_CODE]'
- Example: 'F STP/NC B', 'A MAR S F SPA/SC - WES'
- Coast codes: NC (North), SC (South), EC (East), WC (West)
5. All orders resolve simultaneously
6. Submit orders only, no messages
EXAMPLES
Reasoning:
- Secure Burgundy against German threat
- Mid-Atlantic move enables future convoys
PARSABLE OUTPUT:
{{
"orders": [
"A PAR H",
"A MAR - BUR",
"F BRE - MAO"
]
}}
Example 2: As Germany, Spring 1901, aiming for a swift expansion into DEN and HOL, while also securing home centers.
Reasoning:
- Denmark (DEN) and Holland (HOL) are key neutral centers for early German expansion.
- Need to secure Berlin (BER) and Munich (MUN) against potential French or Russian incursions.
- Kiel (KIE) fleet is best positioned for DEN, while an army from Ruhr (RUH) can take HOL.
PARSABLE OUTPUT:
{{
"orders": [
"A BER H",
"A MUN H",
"F KIE - DEN",
"A RUH - HOL",
"A SIL - WAR", // Opportunistic move towards Warsaw if Russia is weak or focused elsewhere
"F HEL H" // Hold Heligoland Bight for naval defense
]
}}
Example 3: As Italy, Autumn 1902, after securing Tunis and trying to break into Austria, while also defending against a potential French naval attack. My units are A VEN, A ROM, F NAP, F ION, A APU. Austria has F TRI, A VIE, A BUD. France has F WES, F MAR.
Reasoning:
- My primary goal is to take Trieste (TRI) from Austria. Army in Venice (VEN) will attack, supported by Army in Apulia (APU).
- Fleet in Ionian Sea (ION) will support the attack on Trieste from the sea.
- Army in Rome (ROM) will hold to protect the capital.
- Fleet in Naples (NAP) will move to Tyrrhenian Sea (TYS) to defend against a potential French move from Western Mediterranean (WES) towards Naples or Rome.
PARSABLE OUTPUT:
{{
"orders": [
"A VEN - TRI",
"A APU S A VEN - TRI",
"F ION S A VEN - TRI",
"A ROM H",
"F NAP - TYS"
]
}}
RESPOND WITH YOUR REASONING AND ORDERS (within PARSABLE OUTPUT) BELOW

View file

@ -0,0 +1,122 @@
PRIMARY OBJECTIVE
Control 18 supply centers. Nothing else will do.
CRITICAL RULES
1. Only use orders from the provided possible_orders list
2. Support orders must match actual moves (e.g., 'A PAR S F PIC - ENG' needs 'F PIC - ENG')
3. Build orders (build phase only):
- Format: '[UnitType] [Location3LetterCode] B'
- UnitType: 'A' (Army) or 'F' (Fleet)
- Example: 'A PAR B', 'F LON B'
- NOTE YOU CAN ONLY BUILD UNITS IN YOUR HOME CENTER THAT ARE EMPTY, THE ONES YOU STARTED WITH, IF YOU LOSE THESE YOU CANNOT BUILD UNITS SO THEY ARE CRITICAL
Austria
- Budapest
- Trieste
- Vienna
England
- Edinburgh
- Liverpool
- London
France
- Brest
- Marseilles
- Paris
Germany
- Berlin
- Kiel
- Munich
Italy
- Naples
- Rome
- Venice
Russia
- Moscow
- Saint Petersburg
- Sevastopol
- Warsaw
Turkey
- Ankara
- Constantinople
- Smyrna
ORDER SUBMISSION PROCESS
1. ANALYZE
- Review game state, orders, messages, and other powers' motivations
- Focus on expansion and capturing supply centers
- Be aggressive, not passive
- Take calculated risks for significant gains
- Find alternative paths if blocked
2. REASON
- Write out your strategic thinking
- Explain goals and move choices
- Consider supports and holds
3. FORMAT
Return orders in this exact format:
PARSABLE OUTPUT:
{{
"orders": ["order1", "order2", ...]
}}
4. Dual-coast provinces (STP, SPA, BUL):
- Specify coast when needed: 'F [PROVINCE]/[COAST_CODE]'
- Example: 'F STP/NC B', 'A MAR S F SPA/SC - WES'
- Coast codes: NC (North), SC (South), EC (East), WC (West)
5. All orders resolve simultaneously
6. Submit orders only, no messages
EXAMPLES
Reasoning:
- Secure Burgundy against German threat
- Mid-Atlantic move enables future convoys
PARSABLE OUTPUT:
{{
"orders": [
"A PAR H",
"A MAR - BUR",
"F BRE - MAO"
]
}}
Example 2: As Germany, Spring 1901, aiming for a swift expansion into DEN and HOL, while also securing home centers.
Reasoning:
- Denmark (DEN) and Holland (HOL) are key neutral centers for early German expansion.
- Need to secure Berlin (BER) and Munich (MUN) against potential French or Russian incursions.
- Kiel (KIE) fleet is best positioned for DEN, while an army from Ruhr (RUH) can take HOL.
PARSABLE OUTPUT:
{{
"orders": [
"A BER H",
"A MUN H",
"F KIE - DEN",
"A RUH - HOL",
"A SIL - WAR", // Opportunistic move towards Warsaw if Russia is weak or focused elsewhere
"F HEL H" // Hold Heligoland Bight for naval defense
]
}}
Example 3: As Italy, Autumn 1902, after securing Tunis and trying to break into Austria, while also defending against a potential French naval attack. My units are A VEN, A ROM, F NAP, F ION, A APU. Austria has F TRI, A VIE, A BUD. France has F WES, F MAR.
Reasoning:
- My primary goal is to take Trieste (TRI) from Austria. Army in Venice (VEN) will attack, supported by Army in Apulia (APU).
- Fleet in Ionian Sea (ION) will support the attack on Trieste from the sea.
- Army in Rome (ROM) will hold to protect the capital.
- Fleet in Naples (NAP) will move to Tyrrhenian Sea (TYS) to defend against a potential French move from Western Mediterranean (WES) towards Naples or Rome.
PARSABLE OUTPUT:
{{
"orders": [
"A VEN - TRI",
"A APU S A VEN - TRI",
"F ION S A VEN - TRI",
"A ROM H",
"F NAP - TYS"
]
}}
RESPOND WITH YOUR REASONING AND ORDERS (within PARSABLE OUTPUT) BELOW

View file

@ -0,0 +1,3 @@
You are playing as AUSTRIA in the game of Diplomacy.
Your Goal: Achieve world domination by controlling 18 supply centers.

View file

@ -0,0 +1,30 @@
Your power is {power_name}. The {current_phase} phase.
Power: {power_name}
Phase: {current_phase}
# Your Power's Home Centers
{home_centers}
Note: You can only build units in your home centers if they are empty. If you lose control of a home center, you cannot build units there, so holding them is critical.
# Player Status
Current Goals: {agent_goals}
Relationships: {agent_relationships}
# Recent Private Diary Entries (Your inner thoughts and plans):
{agent_private_diary}
# Game Map
Unit Locations:
{all_unit_locations}
Supply Centers Held:
{all_supply_centers}
Possible Orders For {current_phase}
{possible_orders}
End Possible Orders
Messages This Round
{messages_this_round}
End Messages

View file

@ -0,0 +1,28 @@
NEGOTIATION MESSAGES
TASK
Generate one or more (preferably several) strategic messages to advance your interests.
Always prioritize responding to the messages in the "RECENT MESSAGES REQUIRING YOUR ATTENTION" section.
Maintain consistent conversation threads (unless you are choosing to ignore).
RESPONSE FORMAT
Return ONLY a single JSON array containing one or more message objects, remembering to properly escape strings:
Required JSON structure:
[
{
"message_type": "global" or "private",
"content": "Your message text"
},
...
]
For private messages, also include the recipient:
[
{
"message_type": "private",
"recipient": "POWER_NAME",
"content": "Your message text"
},
...
]

View file

@ -0,0 +1,27 @@
DIARY CONSOLIDATION REQUEST
Your Power: {power_name}
GAME CONTEXT
You are playing Diplomacy, a strategic board game set in pre-WWI Europe. Seven powers compete for control by conquering supply centers. Victory requires 18 supply centers.
Key game mechanics:
- Spring (S) and Fall (F) movement phases where armies/fleets move
- Fall phases include builds/disbands based on supply center control
- Units can support, convoy, or attack
- All orders resolve simultaneously
- Success often requires negotiated coordination with other powers
FULL DIARY HISTORY
{full_diary_text}
TASK
Create a comprehensive consolidated summary of the most important parts of this diary history. It will serve as your long-term memory.
Prioritize the following:
1. **Recent Events, Goals & Intentions**
2. **Long-Term Strategy:** Enduring goals, rivalries, and alliances that are still relevant.
3. **Key Historical Events:** Major betrayals, decisive battles, and significant turning points that shape the current diplomatic landscape.
4. **Important Notes:** Any notes you deem important from the history not already included.
RESPONSE FORMAT
Return ONLY the consolidated summary text. Do not include JSON, formatting markers, or meta-commentary.

View file

@ -0,0 +1,3 @@
You are playing as ENGLAND in the game of Diplomacy.
Your Goal: Achieve world domination by controlling 18 supply centers.

View file

@ -0,0 +1,30 @@
EXAMPLE GAME STATE
Power: FRANCE
Phase: S1901M
Your Units: ['A PAR','F BRE']
Possible Orders:
PAR: ['A PAR H','A PAR - BUR','A PAR - GAS']
BRE: ['F BRE H','F BRE - MAO']
PAST PHASE SUMMARIES
- Your move A BUD -> SER bounced last time because Turkey also moved A SMY -> SER with support.
- Your support F TRI S A BUD -> SER was wasted because F TRI was needed to block Ionian invasion.
THINKING PROCESS
1. Consider enemy units, centers, and likely moves
2. Review your units, centers, and strategic position
3. Analyze recent conversations and phase summaries
4. Evaluate public/private goals and reality of positions
5. Choose best strategic moves from possible orders
Example thought process:
- Germany might move to BUR with support - consider bounce or defend
- Moving A PAR -> BUR is aggressive but strategic
- F BRE -> MAO secures Atlantic expansion
- Avoid contradictory or random supports
RESPONSE FORMAT
PARSABLE OUTPUT:
{{
"orders": ["A PAR - BUR","F BRE - MAO"]
}}

View file

@ -0,0 +1,3 @@
You are playing as France in a game of Diplomacy.
Your Goal: Achieve world domination by controlling 18 supply centers.

View file

@ -0,0 +1,3 @@
You are playing as GERMANY in the game of Diplomacy.
Your Goal: Achieve world domination by controlling 18 supply centers.

View file

@ -0,0 +1,3 @@
You are playing as ITALY in the game of Diplomacy.
Your Goal: Achieve world domination by controlling 18 supply centers.

View file

@ -0,0 +1,93 @@
NEGOTIATION SUMMARY REQUEST
Power: {power_name}
Phase: {current_phase}
MESSAGES THIS ROUND
{messages_this_round}
{ignored_messages_context}
CURRENT STATUS
Goals:
{agent_goals}
Relationships:
{agent_relationships}
Game State:
{board_state_str}
TASK
Analyze the negotiations, goals, relationships, and game state to:
1. Summarize key outcomes and agreements
2. State your strategic intent for {current_phase}
3. Update relationships as needed (Enemy, Unfriendly, Neutral, Friendly, Ally)
4. Note which powers are not responding to your messages and consider adjusting your approach
When powers ignore your messages, consider:
- They may be intentionally avoiding commitment
- They could be prioritizing other relationships
- Your approach may need adjustment (more direct questions, different incentives)
- Their silence might indicate hostility or indifference
RESPONSE FORMAT
Return ONLY a JSON object with this structure:
{
"negotiation_summary": "Key outcomes from negotiations",
"intent": "Strategic intent for upcoming orders",
"updated_relationships": {
"POWER_NAME": "Enemy|Unfriendly|Neutral|Friendly|Ally"
}
}
Do not include any text outside the JSON. Reminder: If you need to quote something, only use single quotes in the actual messages so as not to interfere with the JSON structure.
EXAMPLES:
Scenario 1: As France, after discussing a joint move against Germany with England, while Italy seems to be posturing aggressively in Piedmont.
{
"negotiation_summary": "Reached a tentative agreement with England to support their fleet into Belgium (BEL) if they support my army into Ruhr (RUH). Italy's messages are vague but their army in Piedmont (PIE) is concerning; they claim it's defensive against Austria but it also threatens Marseilles (MAR). Russia remains silent. Austria and Turkey are focused on each other.",
"intent": "Secure Ruhr with English support. Hold Marseilles defensively. Probe Italy's intentions further. If England upholds their end, improve relations. If Italy moves on MAR, downgrade relations severely.",
"updated_relationships": {
"ENGLAND": "Friendly",
"GERMANY": "Enemy",
"ITALY": "Unfriendly",
"AUSTRIA": "Neutral",
"RUSSIA": "Neutral",
"TURKEY": "Neutral"
}
}
Scenario 2: As Turkey, after Germany proposed an alliance against Russia, but France also offered a non-aggression pact and hinted at concerns about Austria.
{
"negotiation_summary": "Germany is keen on an anti-Russian alliance, offering support into Sevastopol (SEV) if I attack. France proposed a mutual non-aggression pact and expressed worry about Austrian expansion in the Balkans, which aligns with my concerns. England is distant. Italy seems focused on France.",
"intent": "Prioritize securing Black Sea (BLA) and consider options against Russia, but German support needs to be concrete. Maintain neutrality with France for now, as their non-aggression pact could be useful if Austria becomes a larger threat. Try to confirm German commitment before moving on Russia. Delay any aggressive moves against Austria until my position is stronger.",
"updated_relationships": {
"GERMANY": "Friendly",
"RUSSIA": "Unfriendly",
"FRANCE": "Neutral",
"ENGLAND": "Neutral",
"ITALY": "Neutral",
"AUSTRIA": "Unfriendly"
}
}
Scenario 3: As England, when France hasn't responded to two alliance proposals and Russia is ignoring naval cooperation messages.
{
"negotiation_summary": "France continues to ignore my alliance proposals regarding Belgium and the Channel, having not responded to messages in the last two phases. Russia similarly hasn't acknowledged my Baltic cooperation suggestions. Meanwhile, Germany actively engaged about Denmark. This silence from France and Russia is telling - they likely have other commitments or see me as a threat.",
"intent": "Shift focus to Germany as primary partner given their responsiveness. Prepare defensive positions against potentially hostile France. Consider more aggressive Baltic moves since Russia seems uninterested in cooperation. May need to force France's hand with direct questions or public statements.",
"updated_relationships": {
"FRANCE": "Unfriendly",
"GERMANY": "Friendly",
"RUSSIA": "Unfriendly",
"ITALY": "Neutral",
"AUSTRIA": "Neutral",
"TURKEY": "Neutral"
}
}
Reminder: If you need to quote something, only use single quotes in the actual messages so as not to interfere with the JSON structure.

View file

@ -0,0 +1,27 @@
ORDER DIARY ENTRY
Power: {power_name}
Phase: {current_phase}
ORDERS ISSUED
{orders_list_str}
CURRENT STATUS
Game State:
{board_state_str}
Goals:
{agent_goals}
Relationships:
{agent_relationships}
TASK
Write a concise diary note summarizing your orders.
RESPONSE FORMAT
Return ONLY a JSON object with this structure:
{
"order_summary": "Brief summary of orders and strategic intent"
}
Do not include any text outside the JSON.

View file

@ -0,0 +1,32 @@
# Primary Objective
Control 18 supply centers. Nothing else will do.
# Critical Rules
1. The possible orders section shows your units' allowed adjustment orders
2. Dual-coast provinces (STP, SPA, BUL) require coast specification:
- Format: 'F [PROVINCE]/[COAST]' where [COAST] = NC (North), SC (South), EC (East), or WC (West)
- Example: 'F STP/NC B'
- Only fleet builds need coast specification.
# Adjustment Phase Orders:
You have two main order types in the adjustment phase:
Build: '[UnitType] [Location] B'
e.g. 'A PAR B', 'F LON B'
Disband: '[UnitType] [Location] D'
e.g. 'A PAR D', 'F LON D'
Your Task:
1. Reason
- comprehensive reasoning about your adjustment decisions
2. Output Moves in JSON
- return all build/disband orders needed
Respond with this exact format:
Reasoning:
(Your reasoning goes here)
PARSABLE OUTPUT:
{{
"orders": ["order1", "order2", ...]
}}

View file

@ -0,0 +1,27 @@
# Primary Objective
Control 18 supply centers. Nothing else will do.
# Critical Rules
1. The possible orders section shows your units' allowed moves & supports of your own units.
2. The possible orders section does *not* list possible supports for other powers' units; you can work these out yourself by looking at the units that are adjacent to your own.
3. If your goal is to *take* a province, give exactly one move order on that province and any additional support from other units must be properly formatted support orders.
4. Dual-coast provinces (STP, SPA, BUL) require coast specification:
- Format: 'F [PROVINCE]/[COAST]' where [COAST] = NC (North), SC (South), EC (East), or WC (West)
- Example: 'F SPA/SC - MAO'
- Only fleets need coast specification.
Your Task:
1. Reason
- comprehensive reasoning about your move decisions
2. Output Moves in JSON
- aim to return an order for each of your units.
Respond with this exact format:
Reasoning:
(Your reasoning goes here)
PARSABLE OUTPUT:
{{
"orders": ["order1", "order2", ...]
}}

View file

@ -0,0 +1,30 @@
# Primary Objective
Control 18 supply centers. Nothing else will do.
# Critical Rules
1. The possible orders section shows where your dislodged units can retreat.
2. Units cannot retreat to:
- The province they were dislodged from
- A province occupied after this turn's moves
- A province where a standoff occurred
3. If no valid retreat exists, the unit must disband.
4. Dual-coast provinces (STP, SPA, BUL) require coast specification:
- Format: 'F [PROVINCE]/[COAST]' where [COAST] = NC (North), SC (South), EC (East), or WC (West)
- Example: 'F SPA/SC - MAO'
- Only fleet retreat orders need coast specification.
Your Task:
1. Reason
- comprehensive reasoning about your retreat decisions
2. Output Moves in JSON
- provide a retreat or disband order for each dislodged unit
Respond with this exact format:
Reasoning:
(Your reasoning goes here)
PARSABLE OUTPUT:
{{
"orders": ["order1", "order2", ...]
}}

View file

@ -0,0 +1,42 @@
PHASE RESULT ANALYSIS
Power: {power_name}
Phase: {current_phase}
PHASE SUMMARY
{phase_summary}
ALL POWERS' ORDERS THIS PHASE
{all_orders_formatted}
YOUR NEGOTIATIONS THIS PHASE
{your_negotiations}
YOUR RELATIONSHIPS BEFORE THIS PHASE
{pre_phase_relationships}
YOUR GOALS
{agent_goals}
YOUR ACTUAL ORDERS
{your_actual_orders}
TASK
Analyze what actually happened this phase compared to negotiations and expectations.
Consider:
1. BETRAYALS: Who broke their promises? Did you break any promises?
2. COLLABORATIONS: Which agreements were successfully executed?
3. SURPRISES: What unexpected moves occurred?
4. IMPACT: How did these events affect your strategic position?
Write a reflective diary entry (150-250 words) that:
- Identifies key betrayals or successful collaborations
- Assesses impact on your position
- Updates your understanding of other powers' trustworthiness
- Notes strategic lessons learned
- Adjusts your perception of threats and opportunities
Focus on concrete events and their implications for your future strategy.
RESPONSE FORMAT
Return ONLY a diary entry text. Do not include JSON or formatting markers.

View file

@ -0,0 +1,44 @@
STRATEGIC PLANNING
PRIMARY OBJECTIVE
Capture 18 supply centers to win. Be aggressive and expansionist.
- Prioritize capturing supply centers
- Seize opportunities aggressively
- Take calculated risks for significant gains
- Find alternative paths if blocked
- Avoid purely defensive postures
KEY CONSIDERATIONS
1. Target Supply Centers
- Which centers can you capture this phase?
- Which centers should you target in future phases?
2. Success Requirements
- What must happen for your moves to succeed?
- How to prevent bounces?
3. Diplomatic Strategy
- Which negotiations could help your moves succeed?
- What deals or threats might be effective?
- Consider alliances, deception, and concessions
4. Defense Assessment
- Which of your centers might others target?
- How can you protect vulnerable positions?
5. Diplomatic Protection
- What negotiations could deter attacks?
- How to mislead potential attackers?
TASK
Write a detailed one-paragraph directive covering:
- Supply centers to capture
- How to capture them (orders, allies, deals)
- Defensive considerations
- Diplomatic approach (including potential deception)
This directive will guide your future negotiations and orders.
Be specific, strategic, and wary of deception from others.
RESPOND WITH YOUR DIRECTIVE BELOW

View file

@ -0,0 +1,3 @@
You are playing as RUSSIA in the game of Diplomacy.
Your Goal: Achieve world domination by controlling 18 supply centers.

View file

@ -0,0 +1,139 @@
You are analyzing the results of a phase in Diplomacy for {power_name}.
GAME STATE
Year: {current_year}
Phase: {current_phase}
Board State:
{board_state_str}
PHASE SUMMARY ({current_phase}):
{phase_summary}
CURRENT STATUS
Relationships with other powers ({other_powers}):
{current_relationships}
TASK
Analyze the phase summary and game state to update your relationships and goals.
IMPORTANT RULES
1. Update relationships for ALL powers in {other_powers}
2. Use ONLY these relationship values: Enemy, Unfriendly, Neutral, Friendly, Ally
3. Make goals specific and actionable
4. Base analysis on actual events, not assumptions
5. Return ONLY valid JSON - no text before or after
Example Response Structure:
{{
"reasoning": "Brief explanation of your analysis",
"relationships": {{
"FRANCE": "Neutral",
"GERMANY": "Unfriendly",
"RUSSIA": "Enemy"
}},
"goals": [
"Specific goal 1",
"Specific goal 2"
]
}}
EXAMPLE SCENARIOS
1. After Cooperation:
{{
"reasoning": "Austria helped take Warsaw. Russia attacked Prussia.",
"relationships": {{
"AUSTRIA": "Ally",
"RUSSIA": "Enemy",
"TURKEY": "Neutral",
"ITALY": "Unfriendly",
"FRANCE": "Neutral"
}},
"goals": [
"Hold Warsaw against Russia",
"Keep Austrian alliance",
"Block Italian expansion"
]
}}
2. After Betrayal:
{{
"reasoning": "France betrayed Channel agreement. Russia cooperating north.",
"relationships": {{
"FRANCE": "Enemy",
"RUSSIA": "Friendly",
"GERMANY": "Unfriendly",
"ITALY": "Neutral",
"AUSTRIA": "Neutral"
}},
"goals": [
"Counter French fleet",
"Secure Norway with Russia",
"Build London fleet"
]
}}
3. After Builds:
{{
"reasoning": "Naval buildup in north. Russia threatening.",
"relationships": {{
"RUSSIA": "Enemy",
"GERMANY": "Unfriendly",
"FRANCE": "Neutral",
"AUSTRIA": "Neutral",
"TURKEY": "Neutral"
}},
"goals": [
"Control northern waters",
"Take Denmark first",
"Find anti-Russia ally"
]
}}
4. As England, after a failed attack on Belgium (BEL) which was occupied by France, supported by Germany. Russia moved into Sweden (SWE) uncontested. Austria and Italy skirmished over Trieste (TRI). Turkey was quiet.
{{
"reasoning": "My attack on Belgium was decisively repulsed due to Franco-German cooperation, marking them as a significant threat bloc. Russia's acquisition of Sweden is concerning for my northern position. The Austro-Italian conflict seems localized for now, and Turkey's inactivity makes them an unknown variable, potentially open to diplomacy.",
"relationships": {{
"FRANCE": "Enemy",
"GERMANY": "Enemy",
"RUSSIA": "Unfriendly",
"AUSTRIA": "Neutral",
"ITALY": "Neutral",
"TURKEY": "Neutral"
}},
"goals": [
"Break the Franco-German alliance or find a way to counter their combined strength.",
"Secure North Sea (NTH) and prevent further Russian expansion towards Norway (NWY).",
"Seek dialogue with Turkey or Austria/Italy to create a counterweight to the dominant bloc."
]
}}
5. As Russia, after successfully negotiating passage through Black Sea (BLA) with Turkey to take Rumania (RUM). Germany moved into Silesia (SIL), threatening Warsaw (WAR). Austria and France exchanged hostile messages but made no direct moves against each other. England built a new fleet in London (LON). Italy seems focused west.
{{
"reasoning": "Securing Rumania via Turkish agreement is a major success. This improves my southern position and Turkey is now a provisional ally. Germany's move into Silesia is a direct and immediate threat to Warsaw; they are now my primary adversary. Austria and France are posturing, but their conflict doesn't directly affect me yet, keeping them neutral. England's new fleet is a long-term concern but not immediate. Italy's westward focus means they are not a current threat or priority.",
"relationships": {{
"GERMANY": "Enemy",
"AUSTRIA": "Neutral",
"TURKEY": "Ally",
"ITALY": "Neutral",
"FRANCE": "Neutral",
"ENGLAND": "Unfriendly"
}},
"goals": [
"Defend Warsaw against Germany, possibly by moving Lvn-War or Mos-War.",
"Solidify alliance with Turkey, potentially coordinating further moves in the south or against Austria if Germany allies with them.",
"Monitor English fleet movements and prepare for a potential northern threat in future turns.",
"Explore diplomatic options with France or Austria to counter German aggression."
]
}}
JSON FORMAT
Return a single JSON object with these exact keys:
- reasoning: String explaining your updates
- relationships: Object mapping power names to relationship values
- goals: Array of specific goal strings
RETURN JSON BELOW ONLY (DO NOT PREPEND WITH ```json or ``` or any other text)

View file

@ -0,0 +1,3 @@
You are playing as TURKEY in the game of Diplomacy.
Your Goal: Achieve world domination by controlling 18 supply centers.

View file

@ -294,22 +294,37 @@ def normalize_and_compare_orders(
# Helper to load prompt text from file relative to the expected 'prompts' dir
def load_prompt(filename: str, prompts_dir: Optional[str] = None) -> str:
"""Helper to load prompt text from file"""
if prompts_dir:
"""
Return the contents of *filename* while never joining paths twice.
Logic
-----
1. If *filename* is absolute use it directly.
2. Elif *filename* already contains a path component (e.g. 'x/y.txt')
treat it as a relative path and use it directly.
3. Elif *prompts_dir* is provided join prompts_dir + filename.
4. Otherwise join the packages default prompts dir.
"""
if os.path.isabs(filename): # rule 1
prompt_path = filename
elif os.path.dirname(filename): # rule 2 (has slash)
prompt_path = filename # relative but already complete
elif prompts_dir: # rule 3
prompt_path = os.path.join(prompts_dir, filename)
else:
# Default behavior: relative to this file's location in the 'prompts' subdir
prompt_path = os.path.join(os.path.dirname(__file__), 'prompts', filename)
else: # rule 4
default_dir = os.path.join(os.path.dirname(__file__), "prompts")
prompt_path = os.path.join(default_dir, filename)
try:
with open(prompt_path, "r", encoding='utf-8') as f: # Added encoding
return f.read().strip()
with open(prompt_path, "r", encoding="utf-8") as fh:
return fh.read().strip()
except FileNotFoundError:
logger.error(f"Prompt file not found: {prompt_path}")
# Return an empty string or raise an error, depending on desired handling
return ""
# == New LLM Response Logging Function ==
def log_llm_response(
log_file_path: str,

View file

@ -521,6 +521,7 @@ class StatisticalGameAnalyzer:
"""Extract game-level features (placeholder for future implementation)."""
game_features = []
game_scores = self._compute_game_scores(game_data)
for power in self.powers:
features = {
@ -568,6 +569,8 @@ class StatisticalGameAnalyzer:
'overall_success_rate_percentage': 0.0,
}
features['game_score'] = game_scores.get(power)
# === CALCULATE FINAL STATE METRICS ===
if game_data['phases']:
@ -902,6 +905,88 @@ class StatisticalGameAnalyzer:
return territory
return unit_str
# ───────────────── Diplobench style game score ──────────────────
@staticmethod
def _year_from_phase(name: str) -> int | None:
"""Return the 4-digit year embedded in a phase name such as 'F1903M'."""
m = re.search(r'(\d{4})', name)
return int(m.group(1)) if m else None
def _phase_year(self, phases, idx: int) -> int | None:
"""
Like _year_from_phase but walks backward if a phase itself has no year
(e.g. 'COMPLETED'). Returns None if nothing is found.
"""
for j in range(idx, -1, -1):
y = self._year_from_phase(phases[j]["name"])
if y is not None:
return y
return None
def _compute_game_scores(self, game_data: dict) -> dict[str, int]:
"""
Return {power game_score} using the Diplobench scheme.
max_turns = number of *years* actually played
solo winner max_turns + 17 + (max_turns win_turn)
full-length survivor (no solo) max_turns + final_SCs
everyone else elimination_turn (or win_turn if someone else solos)
"""
phases = game_data.get("phases", [])
if not phases:
return {}
# years played
years = [self._year_from_phase(p["name"]) for p in phases if self._year_from_phase(p["name"]) is not None]
if not years:
return {}
start_year, last_year = years[0], years[-1]
max_turns = last_year - start_year + 1
# solo winner?
solo_winner = None
win_turn = None
last_state = phases[-1]["state"]
for pwr, scs in last_state.get("centers", {}).items():
if len(scs) >= 18:
solo_winner = pwr
# first phase in which 18+ SCs were reached
for idx in range(len(phases) - 1, -1, -1):
if len(phases[idx]["state"]["centers"].get(pwr, [])) >= 18:
yr = self._phase_year(phases, idx)
if yr is not None:
win_turn = yr - start_year + 1
break
break
# elimination turn for every power
elim_turn: dict[str, int | None] = {p: None for p in self.DIPLOMACY_POWERS}
for idx, ph in enumerate(phases):
yr = self._phase_year(phases, idx)
if yr is None:
continue
turn = yr - start_year + 1
for pwr in elim_turn:
if elim_turn[pwr] is None and not ph["state"]["centers"].get(pwr):
elim_turn[pwr] = turn
scores: dict[str, int] = {}
for pwr in elim_turn:
if pwr == solo_winner:
scores[pwr] = max_turns + 17 + (max_turns - (win_turn or max_turns))
elif solo_winner is not None: # somebody else soloed
scores[pwr] = win_turn or max_turns
else: # no solo
if elim_turn[pwr] is None: # survived the distance
final_scs = len(last_state.get("centers", {}).get(pwr, []))
scores[pwr] = max_turns + final_scs
else: # eliminated earlier
scores[pwr] = elim_turn[pwr]
return scores
def _load_csv_as_dicts(self, csv_path: str) -> List[dict]:
"""Load CSV file as list of dictionaries."""
data = []
@ -1034,7 +1119,10 @@ class StatisticalGameAnalyzer:
'avg_military_units_per_phase',
'percent_messages_to_allies_overall',
'percent_messages_to_enemies_overall',
'percent_global_vs_private_overall'
'percent_global_vs_private_overall',
# === Diplobench style single scalar game score ===
'game_score',
]
# Ensure all actual fields are included

View file

@ -71,7 +71,7 @@ def _add_experiment_flags(p: argparse.ArgumentParser) -> None:
p.add_argument(
"--analysis_modules",
type=str,
default="summary",
default="summary,statistical_game_analysis",
help=(
"Comma-separated list of analysis module names to execute after all "
"runs finish. Modules are imported from "

View file

@ -0,0 +1,214 @@
"""
experiment_runner.analysis.statistical_game_analysis
----------------------------------------------------
Runs the Statistical Game Analyzer to create per-run / combined CSVs,
then produces a suite of PNG plots:
analysis/
statistical_game_analysis/
individual/
run_00000_game_analysis.csv
plots/
game/
final_supply_centers_owned.png
game_summary_heatmap.png
phase/
supply_centers_owned_count.png
Complies with experiment-runners plug-in contract:
run(experiment_dir: pathlib.Path, ctx: dict) -> None
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import List
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
# third-party analyser that creates the CSVs
from analysis.statistical_game_analysis import StatisticalGameAnalyzer # type: ignore
log = logging.getLogger(__name__)
# ───────────────────────── helpers ──────────────────────────
_SEASON_ORDER = {"S": 0, "F": 1, "W": 2, "A": 3}
def _sanitize(name: str) -> str:
return re.sub(r"[^\w\-\.]", "_", name)
def _discover_csvs(individual_dir: Path, pattern: str) -> List[Path]:
return sorted(individual_dir.glob(pattern))
def _numeric_columns(df: pd.DataFrame, extra_exclude: set[str] | None = None) -> List[str]:
exclude = {
"game_id",
"llm_model",
"power_name",
"game_phase",
"analyzed_response_type",
}
if extra_exclude:
exclude |= extra_exclude
return [c for c in df.select_dtypes("number").columns if c not in exclude]
def _phase_sort_key(ph: str) -> tuple[int, int]:
"""Convert 'S1901M' → (1901, 0)."""
year = int(ph[1:5]) if len(ph) >= 5 and ph[1:5].isdigit() else 0
season = _SEASON_ORDER.get(ph[0], 9)
return year, season
def _phase_index(series: pd.Series) -> pd.Series:
uniq = sorted(series.unique(), key=_phase_sort_key)
mapping = {ph: i for i, ph in enumerate(uniq)}
return series.map(mapping)
# ───────────────────────── plots ────────────────────────────
def _plot_game_level(all_games: pd.DataFrame, plot_dir: Path) -> None:
"""
Box-plots per metric (hue = power, legend removed).
Z-score heat-map: powers × metrics, colour-coded by relative standing.
"""
plot_dir.mkdir(parents=True, exist_ok=True)
num_cols = _numeric_columns(all_games)
# ── per-metric box-plots ──────────────────────────────────────────
for col in num_cols:
fig, ax = plt.subplots(figsize=(8, 6))
sns.boxplot(
data=all_games,
x="power_name",
y=col,
hue="power_name",
palette="pastel",
dodge=False,
ax=ax,
)
leg = ax.get_legend()
if leg is not None:
leg.remove()
ax.set_title(col.replace("_", " ").title())
fig.tight_layout()
fig.savefig(plot_dir / f"{_sanitize(col)}.png", dpi=140)
plt.close(fig)
# ── summary heat-map (column-wise z-scores) ───────────────────────
# 1) mean across runs 2) z-score each column
summary = all_games.groupby("power_name")[num_cols].mean().sort_index()
zscores = summary.apply(lambda col: (col - col.mean()) / col.std(ddof=0), axis=0)
fig_w = max(6, len(num_cols) * 0.45 + 2)
fig, ax = plt.subplots(figsize=(fig_w, 6))
sns.heatmap(
zscores,
cmap="coolwarm",
center=0,
linewidths=0.4,
annot=True,
fmt=".2f",
ax=ax,
)
ax.set_title("Relative Standing (column-wise z-score)")
ax.set_ylabel("Power")
fig.tight_layout()
fig.savefig(plot_dir.parent / "game_summary_zscore_heatmap.png", dpi=160)
plt.close(fig)
def _plot_phase_level(all_phase: pd.DataFrame, plot_dir: Path) -> None:
if all_phase.empty:
return
plot_dir.mkdir(parents=True, exist_ok=True)
if "phase_index" not in all_phase.columns:
all_phase["phase_index"] = _phase_index(all_phase["game_phase"])
num_cols = _numeric_columns(all_phase)
agg = (
all_phase
.groupby(["phase_index", "game_phase", "power_name"], as_index=False)[num_cols]
.mean()
)
n_phases = agg["phase_index"].nunique()
fig_base_width = max(8, n_phases * 0.1 + 4) # 0.45 in per label + padding
for col in num_cols:
plt.figure(figsize=(fig_base_width, 6))
sns.lineplot(
data=agg,
x="phase_index",
y=col,
hue="power_name",
marker="o",
)
phases_sorted = (
agg.drop_duplicates("phase_index")
.sort_values("phase_index")[["phase_index", "game_phase"]]
)
plt.xticks(
phases_sorted["phase_index"],
phases_sorted["game_phase"],
rotation=90,
fontsize=8,
)
plt.xlabel("Game Phase")
plt.title(col.replace("_", " ").title())
plt.tight_layout()
plt.savefig(plot_dir / f"{_sanitize(col)}.png", dpi=140)
plt.close()
# ───────────────────────── entry-point ─────────────────────────
def run(experiment_dir: Path, ctx: dict) -> None: # pylint: disable=unused-argument
root = experiment_dir / "analysis" / "statistical_game_analysis"
indiv_dir = root / "individual"
plots_root = root / "plots"
# 1. (re)generate CSVs
try:
StatisticalGameAnalyzer().analyze_multiple_folders(
str(experiment_dir / "runs"), str(root)
)
log.info("statistical_game_analysis: CSV generation complete")
except Exception as exc: # noqa: broad-except
log.exception("statistical_game_analysis: CSV generation failed %s", exc)
return
# 2. load CSVs
game_csvs = _discover_csvs(indiv_dir, "*_game_analysis.csv")
phase_csvs = _discover_csvs(indiv_dir, "*_phase_analysis.csv")
if not game_csvs:
log.warning("statistical_game_analysis: no *_game_analysis.csv found")
return
all_game_df = pd.concat((pd.read_csv(p) for p in game_csvs), ignore_index=True)
all_phase_df = (
pd.concat((pd.read_csv(p) for p in phase_csvs), ignore_index=True)
if phase_csvs else pd.DataFrame()
)
# 3. plots
sns.set_theme(style="whitegrid")
_plot_game_level(all_game_df, plots_root / "game")
_plot_phase_level(all_phase_df, plots_root / "phase")
log.info("statistical_game_analysis: plots written → %s", plots_root)

View file

@ -9,6 +9,7 @@ from collections import defaultdict
from argparse import Namespace
from typing import Dict
import shutil
import sys
# Suppress Gemini/PaLM gRPC warnings
os.environ["GRPC_PYTHON_LOG_LEVEL"] = "40" # ERROR level only
@ -24,7 +25,7 @@ from ai_diplomacy.negotiations import conduct_negotiations
from ai_diplomacy.planning import planning_phase
from ai_diplomacy.game_history import GameHistory
from ai_diplomacy.agent import DiplomacyAgent
import ai_diplomacy.narrative
# import ai_diplomacy.narrative
from ai_diplomacy.game_logic import (
save_game_state,
load_game_state,
@ -45,6 +46,14 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
# logging.getLogger("root").setLevel(logging.WARNING) # Assuming root handles AFC
def _str2bool(v: str) -> bool:
v = str(v).lower()
if v in {"1", "true", "t", "yes", "y"}:
return True
if v in {"0", "false", "f", "no", "n"}:
return False
raise argparse.ArgumentTypeError(f"Boolean value expected, got '{v}'")
def parse_arguments():
parser = argparse.ArgumentParser(
description="Run a Diplomacy game simulation with configurable parameters."
@ -129,6 +138,16 @@ def parse_arguments():
default=None,
help="Path to the directory containing prompt files. Defaults to the packaged prompts directory."
)
parser.add_argument(
"--simple_prompts",
type=_str2bool,
nargs="?",
const=True,
default=False,
help=(
"When true (1 / true / yes) the engine switches to simpler prompts which low-midrange models handle better."
),
)
return parser.parse_args()
@ -137,6 +156,17 @@ async def main():
args = parse_arguments()
start_whole = time.time()
# honour --simple_prompts before anything else needs it
if args.simple_prompts:
os.environ["SIMPLE_PROMPTS"] = "1" # read by prompt_constructor
if args.prompts_dir is None:
pkg_root = os.path.join(os.path.dirname(__file__), "ai_diplomacy")
args.prompts_dir = os.path.join(pkg_root, "prompts_simple")
if args.prompts_dir and not os.path.isdir(args.prompts_dir):
print(f"ERROR: Prompts directory not found: {args.prompts_dir}", file=sys.stderr)
sys.exit(1)
# --- 1. Determine Run Directory and Mode (New vs. Resume) ---
run_dir = args.run_dir
is_resuming = False
@ -197,7 +227,8 @@ async def main():
if is_resuming:
try:
# When resuming, we load the state and also the config from the last saved phase.
game, agents, game_history, loaded_run_config = load_game_state(run_dir, game_file_name, args.resume_from_phase)
game, agents, game_history, loaded_run_config = load_game_state(run_dir, game_file_name, run_config, args.resume_from_phase)
if loaded_run_config:
# Use the saved config, but allow current CLI args to override control-flow parameters
run_config = loaded_run_config