Spaces:
Sleeping
Sleeping
| """Game runner for Codenames that plays full games and records all interactions. | |
| Provides utilities to run games, record transcripts, and save game histories. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| import os | |
| import random | |
| from dataclasses import dataclass, field, asdict | |
| from datetime import datetime | |
| from pathlib import Path | |
| from typing import Any | |
| from watchdog_env.plugins.codenames.codenames_config import CodenamesConfig | |
| from watchdog_env.plugins.codenames.codenames_plugin import CodenamesPlugin | |
| from watchdog_env.plugins.base import AgentTurn | |
| logger = logging.getLogger(__name__) | |
| class StepRecord: | |
| """Record of a single step in the game.""" | |
| step_index: int | |
| agent_id: str | |
| action_text: str | |
| phase: str | |
| metadata: dict[str, Any] = field(default_factory=dict) | |
| game_state_snapshot: dict[str, Any] | None = None | |
| def to_dict(self) -> dict[str, Any]: | |
| return { | |
| "step_index": self.step_index, | |
| "agent_id": self.agent_id, | |
| "action_text": self.action_text, | |
| "phase": self.phase, | |
| "metadata": self.metadata, | |
| "game_state_snapshot": self.game_state_snapshot, | |
| } | |
| class GameRecord: | |
| """Complete record of a Codenames game.""" | |
| game_id: str | |
| config: CodenamesConfig | |
| seed: int | None | |
| start_time: str | |
| end_time: str | None = None | |
| winner: str | None = None | |
| game_over_reason: str | None = None | |
| total_steps: int = 0 | |
| total_turns: int = 0 | |
| steps: list[StepRecord] = field(default_factory=list) | |
| initial_board: dict[str, Any] = field(default_factory=dict) | |
| final_state: dict[str, Any] = field(default_factory=dict) | |
| def to_dict(self) -> dict[str, Any]: | |
| return { | |
| "game_id": self.game_id, | |
| "config": { | |
| "board_size": self.config.board_size, | |
| "red_words": self.config.red_words, | |
| "blue_words": self.config.blue_words, | |
| "neutral_words": self.config.neutral_words, | |
| "assassin_words": self.config.assassin_words, | |
| "starting_team": self.config.starting_team, | |
| "max_turns": self.config.max_turns, | |
| "complexity_level": self.config.complexity_level, | |
| "model_name": self.config.model_name, | |
| "temperature": self.config.temperature, | |
| }, | |
| "seed": self.seed, | |
| "start_time": self.start_time, | |
| "end_time": self.end_time, | |
| "winner": self.winner, | |
| "game_over_reason": self.game_over_reason, | |
| "total_steps": self.total_steps, | |
| "total_turns": self.total_turns, | |
| "steps": [s.to_dict() for s in self.steps], | |
| "initial_board": self.initial_board, | |
| "final_state": self.final_state, | |
| } | |
| def to_json(self, indent: int = 2) -> str: | |
| return json.dumps(self.to_dict(), indent=indent) | |
| def save(self, filepath: str | Path) -> None: | |
| """Save the game record to a JSON file.""" | |
| filepath = Path(filepath) | |
| filepath.parent.mkdir(parents=True, exist_ok=True) | |
| with open(filepath, "w") as f: | |
| f.write(self.to_json()) | |
| logger.info("Saved game record to %s", filepath) | |
| def from_dict(cls, data: dict[str, Any]) -> "GameRecord": | |
| """Load a game record from a dictionary.""" | |
| config_data = data.get("config", {}) | |
| config = CodenamesConfig( | |
| board_size=config_data.get("board_size", 25), | |
| red_words=config_data.get("red_words", 9), | |
| blue_words=config_data.get("blue_words", 8), | |
| neutral_words=config_data.get("neutral_words", 7), | |
| assassin_words=config_data.get("assassin_words", 1), | |
| starting_team=config_data.get("starting_team", "red"), | |
| max_turns=config_data.get("max_turns", 20), | |
| complexity_level=config_data.get("complexity_level", 2), | |
| model_name=config_data.get("model_name", "gemini-2.0-flash"), | |
| temperature=config_data.get("temperature", 0.7), | |
| ) | |
| steps = [ | |
| StepRecord( | |
| step_index=s["step_index"], | |
| agent_id=s["agent_id"], | |
| action_text=s["action_text"], | |
| phase=s["phase"], | |
| metadata=s.get("metadata", {}), | |
| game_state_snapshot=s.get("game_state_snapshot"), | |
| ) | |
| for s in data.get("steps", []) | |
| ] | |
| return cls( | |
| game_id=data.get("game_id", ""), | |
| config=config, | |
| seed=data.get("seed"), | |
| start_time=data.get("start_time", ""), | |
| end_time=data.get("end_time"), | |
| winner=data.get("winner"), | |
| game_over_reason=data.get("game_over_reason"), | |
| total_steps=data.get("total_steps", 0), | |
| total_turns=data.get("total_turns", 0), | |
| steps=steps, | |
| initial_board=data.get("initial_board", {}), | |
| final_state=data.get("final_state", {}), | |
| ) | |
| def load(cls, filepath: str | Path) -> "GameRecord": | |
| """Load a game record from a JSON file.""" | |
| with open(filepath) as f: | |
| data = json.load(f) | |
| return cls.from_dict(data) | |
| class CodenamesGameRunner: | |
| """Runner to play Codenames games and record all interactions.""" | |
| def __init__( | |
| self, | |
| record_state_snapshots: bool = True, | |
| max_steps: int = 200, # Safety limit | |
| ): | |
| self.record_state_snapshots = record_state_snapshots | |
| self.max_steps = max_steps | |
| def run_game( | |
| self, | |
| config: CodenamesConfig | None = None, | |
| seed: int | None = None, | |
| verbose: bool = False, | |
| ) -> GameRecord: | |
| """Run a complete Codenames game. | |
| Args: | |
| config: Game configuration (uses defaults if None) | |
| seed: Random seed for reproducibility | |
| verbose: Print game progress to console | |
| Returns: | |
| GameRecord with all interactions recorded | |
| """ | |
| if config is None: | |
| config = CodenamesConfig() | |
| if seed is None: | |
| seed = random.randint(0, 2**31 - 1) | |
| # Initialize plugin | |
| plugin = CodenamesPlugin() | |
| plugin.reset(seed=seed, config=config) | |
| # Create game record | |
| game_id = f"codenames_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{seed}" | |
| start_time = datetime.now().isoformat() | |
| # Record initial board state | |
| initial_state = plugin.get_full_game_state() | |
| initial_board = initial_state.get("game_state", {}).get("board", {}) | |
| record = GameRecord( | |
| game_id=game_id, | |
| config=config, | |
| seed=seed, | |
| start_time=start_time, | |
| initial_board=initial_board, | |
| ) | |
| if verbose: | |
| print(f"\n{'='*60}") | |
| print(f"Starting Codenames Game: {game_id}") | |
| print(f"Complexity Level: {config.complexity_level}") | |
| print(f"Seed: {seed}") | |
| print(f"{'='*60}\n") | |
| # Print initial board | |
| game_state = plugin.get_game_state() | |
| if game_state: | |
| print("Board:") | |
| for row in game_state.board.grid: | |
| print(" " + " | ".join(f"{w:12}" for w in row)) | |
| print() | |
| # Run game loop | |
| step_index = 0 | |
| while not plugin.get_state().done and step_index < self.max_steps: | |
| step = plugin.generate_step(seed=seed, step_index=step_index) | |
| for turn in step.turns: | |
| step_record = StepRecord( | |
| step_index=turn.step_index, | |
| agent_id=turn.agent_id, | |
| action_text=turn.action_text, | |
| phase=turn.phase, | |
| metadata=dict(turn.metadata), | |
| ) | |
| if self.record_state_snapshots: | |
| step_record.game_state_snapshot = plugin.get_full_game_state() | |
| record.steps.append(step_record) | |
| if verbose: | |
| team = "RED" if "red" in turn.agent_id else "BLUE" | |
| role = "Spymaster" if "spymaster" in turn.agent_id else "Operative" | |
| print(f"[Step {step_index}] {team} {role}: {turn.action_text}") | |
| step_index += 1 | |
| if step.done: | |
| break | |
| # Record final state | |
| final_state = plugin.get_full_game_state() | |
| game_state = plugin.get_game_state() | |
| record.end_time = datetime.now().isoformat() | |
| record.total_steps = step_index | |
| record.final_state = final_state | |
| if game_state: | |
| record.winner = game_state.winner | |
| record.game_over_reason = game_state.game_over_reason | |
| record.total_turns = game_state.turn_number | |
| if verbose: | |
| print(f"\n{'='*60}") | |
| print(f"Game Over!") | |
| if record.winner: | |
| print(f"Winner: {record.winner.upper()}") | |
| else: | |
| print("Result: DRAW") | |
| print(f"Reason: {record.game_over_reason}") | |
| print(f"Total Steps: {record.total_steps}") | |
| print(f"Total Turns: {record.total_turns}") | |
| print(f"{'='*60}\n") | |
| return record | |
| def run_multiple_games( | |
| self, | |
| num_games: int, | |
| config: CodenamesConfig | None = None, | |
| base_seed: int | None = None, | |
| verbose: bool = False, | |
| save_dir: str | Path | None = None, | |
| ) -> list[GameRecord]: | |
| """Run multiple games and optionally save records. | |
| Args: | |
| num_games: Number of games to run | |
| config: Game configuration (uses defaults if None) | |
| base_seed: Base seed (game i uses base_seed + i) | |
| verbose: Print progress | |
| save_dir: Directory to save game records | |
| Returns: | |
| List of GameRecords | |
| """ | |
| if base_seed is None: | |
| base_seed = random.randint(0, 2**31 - 1) | |
| records = [] | |
| for i in range(num_games): | |
| seed = base_seed + i | |
| if verbose: | |
| print(f"\nRunning game {i+1}/{num_games} (seed={seed})...") | |
| record = self.run_game(config=config, seed=seed, verbose=verbose) | |
| records.append(record) | |
| if save_dir: | |
| save_path = Path(save_dir) / f"{record.game_id}.json" | |
| record.save(save_path) | |
| # Print summary | |
| if verbose and records: | |
| red_wins = sum(1 for r in records if r.winner == "red") | |
| blue_wins = sum(1 for r in records if r.winner == "blue") | |
| draws = sum(1 for r in records if r.winner is None) | |
| print(f"\n{'='*60}") | |
| print(f"Summary of {num_games} games:") | |
| print(f" Red wins: {red_wins} ({100*red_wins/num_games:.1f}%)") | |
| print(f" Blue wins: {blue_wins} ({100*blue_wins/num_games:.1f}%)") | |
| print(f" Draws: {draws} ({100*draws/num_games:.1f}%)") | |
| assassin_losses = sum(1 for r in records if r.game_over_reason == "assassin") | |
| all_words_wins = sum(1 for r in records if r.game_over_reason == "all_words") | |
| max_turns_ends = sum(1 for r in records if r.game_over_reason == "max_turns") | |
| print(f"\nEnd reasons:") | |
| print(f" All words found: {all_words_wins}") | |
| print(f" Assassin hit: {assassin_losses}") | |
| print(f" Max turns reached: {max_turns_ends}") | |
| print(f"{'='*60}\n") | |
| return records | |
| def run_demo_game(complexity_level: int = 2, verbose: bool = True) -> GameRecord: | |
| """Run a single demo game with default settings. | |
| This is a convenience function for quick testing. | |
| """ | |
| config = CodenamesConfig(complexity_level=complexity_level) | |
| runner = CodenamesGameRunner() | |
| return runner.run_game(config=config, verbose=verbose) | |
| def print_game_transcript(record: GameRecord) -> None: | |
| """Print a formatted transcript of a game.""" | |
| print(f"\n{'='*70}") | |
| print(f"CODENAMES GAME TRANSCRIPT") | |
| print(f"Game ID: {record.game_id}") | |
| print(f"Started: {record.start_time}") | |
| print(f"Ended: {record.end_time}") | |
| print(f"{'='*70}\n") | |
| print("CONFIGURATION:") | |
| print(f" Complexity: {record.config.complexity_level}") | |
| print(f" Red words: {record.config.red_words}") | |
| print(f" Blue words: {record.config.blue_words}") | |
| print(f" Starting team: {record.config.starting_team}") | |
| print() | |
| print("INITIAL BOARD:") | |
| grid = record.initial_board.get("grid", []) | |
| assignments = record.initial_board.get("assignments", {}) | |
| for row in grid: | |
| row_str = " | ".join(f"{w:12}" for w in row) | |
| print(f" {row_str}") | |
| print() | |
| print("WORD ASSIGNMENTS:") | |
| for team in ["red", "blue", "neutral", "assassin"]: | |
| words = [w for w, t in assignments.items() if t == team] | |
| print(f" {team.upper()}: {', '.join(words)}") | |
| print() | |
| print("GAME TRANSCRIPT:") | |
| print("-" * 70) | |
| current_turn = -1 | |
| for step in record.steps: | |
| turn = step.metadata.get("turn_number", step.step_index // 2) | |
| if turn != current_turn: | |
| current_turn = turn | |
| print(f"\n--- Turn {turn} ---") | |
| team = "RED" if "red" in step.agent_id else "BLUE" | |
| role = "Spymaster" if "spymaster" in step.agent_id else "Operative" | |
| print(f"[{team} {role}] {step.action_text}") | |
| print(f"\n{'-'*70}") | |
| print(f"RESULT: {record.winner.upper() if record.winner else 'DRAW'}") | |
| print(f"Reason: {record.game_over_reason}") | |
| print(f"Total turns: {record.total_turns}") | |
| print(f"Total steps: {record.total_steps}") | |
| print(f"{'='*70}\n") | |
| if __name__ == "__main__": | |
| # Run a demo game when executed directly | |
| import sys | |
| complexity = int(sys.argv[1]) if len(sys.argv) > 1 else 2 | |
| record = run_demo_game(complexity_level=complexity, verbose=True) | |
| # Optionally save | |
| if len(sys.argv) > 2: | |
| record.save(sys.argv[2]) | |