|
| 1 | +"""Bridge Arena for CodeClash.""" |
| 2 | + |
| 3 | +import json |
| 4 | +import sys |
| 5 | +from concurrent.futures import ThreadPoolExecutor, as_completed |
| 6 | +from pathlib import Path |
| 7 | + |
| 8 | +from tqdm.auto import tqdm |
| 9 | + |
| 10 | +from codeclash.agents.player import Player |
| 11 | +from codeclash.arenas.arena import CodeArena, RoundStats |
| 12 | +from codeclash.constants import RESULT_TIE |
| 13 | + |
| 14 | + |
| 15 | +class BridgeArena(CodeArena): |
| 16 | + name: str = "Bridge" |
| 17 | + submission: str = "bridge_agent.py" |
| 18 | + description: str = """Bridge is a 4-player trick-taking card game played in teams. |
| 19 | +
|
| 20 | +Teams: North/South (positions 0/2) vs East/West (positions 1/3) |
| 21 | +
|
| 22 | +Your bot (bridge_agent.py) must implement these functions: |
| 23 | +- get_bid(game_state) -> str: Make bidding decisions, return bid string like "1H", "2NT", "PASS" |
| 24 | +- play_card(game_state) -> str: Play a card, return card string like "AS", "7H" |
| 25 | +
|
| 26 | +game_state is a dict containing: |
| 27 | +- position: Your position (0=North, 1=East, 2=South, 3=West) |
| 28 | +- hand: List of cards in your hand (e.g., ["AS", "KH", "7D"]) |
| 29 | +- bids: List of previous bids |
| 30 | +- legal_bids: List of legal bids you can make (during bidding) |
| 31 | +- legal_cards: List of legal cards you can play (during playing) |
| 32 | +- current_trick: Cards played so far in current trick |
| 33 | +- contract: The current contract (if bidding is complete) |
| 34 | +""" |
| 35 | + default_args: dict = { |
| 36 | + "sims_per_round": 10, |
| 37 | + } |
| 38 | + |
| 39 | + def __init__(self, config, **kwargs): |
| 40 | + # Validate player count before initializing (to avoid Docker build on invalid config) |
| 41 | + num_players = len(config.get("players", [])) |
| 42 | + if num_players != 4: |
| 43 | + raise ValueError(f"Bridge requires exactly 4 players, got {num_players}") |
| 44 | + super().__init__(config, **kwargs) |
| 45 | + |
| 46 | + def validate_code(self, agent: Player) -> tuple[bool, str | None]: |
| 47 | + """Validate agent code has required functions.""" |
| 48 | + if self.submission not in agent.environment.execute("ls")["output"]: |
| 49 | + return False, f"No {self.submission} file found in root directory" |
| 50 | + |
| 51 | + content = agent.environment.execute(f"cat {self.submission}")["output"] |
| 52 | + |
| 53 | + # Check for required function definitions |
| 54 | + required_functions = [ |
| 55 | + "def get_bid(", |
| 56 | + "def play_card(" |
| 57 | + ] |
| 58 | + |
| 59 | + missing = [] |
| 60 | + for func in required_functions: |
| 61 | + if func not in content: |
| 62 | + missing.append(func) |
| 63 | + |
| 64 | + if missing: |
| 65 | + return False, f"Missing required functions: {', '.join(missing)}" |
| 66 | + |
| 67 | + return True, None |
| 68 | + |
| 69 | + def _run_single_simulation(self, agents: list[Player], idx: int): |
| 70 | + """Run a single Bridge game simulation.""" |
| 71 | + try: |
| 72 | + # Import BridgeGame from game_server |
| 73 | + sys.path.insert(0, str(Path(self.environment.config.cwd) / "game_server")) |
| 74 | + from game import BridgeGame |
| 75 | + |
| 76 | + # Create game with random seed for reproducibility |
| 77 | + game = BridgeGame(seed=idx, dealer=idx % 4) |
| 78 | + |
| 79 | + # Add players at positions 0-3 (North, East, South, West) |
| 80 | + for position, agent in enumerate(agents): |
| 81 | + game.add_player(position, agent.name) |
| 82 | + |
| 83 | + # Start game (deals cards) |
| 84 | + if not game.start_game(): |
| 85 | + self.logger.error(f"Simulation {idx}: Failed to start game") |
| 86 | + return |
| 87 | + |
| 88 | + # Import agent modules dynamically |
| 89 | + agent_modules = [] |
| 90 | + for agent in agents: |
| 91 | + agent_path = Path(agent.environment.config.cwd) / self.submission |
| 92 | + spec = __import__( |
| 93 | + 'importlib.util' |
| 94 | + ).util.spec_from_file_location(f"agent_{agent.name}", agent_path) |
| 95 | + module = __import__('importlib.util').util.module_from_spec(spec) |
| 96 | + spec.loader.exec_module(module) |
| 97 | + agent_modules.append(module) |
| 98 | + |
| 99 | + # Bidding phase |
| 100 | + while game.phase == 'bidding': |
| 101 | + current_pos = game.current_player |
| 102 | + state = game.get_state(current_pos) |
| 103 | + |
| 104 | + # Prepare game_state for agent |
| 105 | + agent_state = { |
| 106 | + 'position': current_pos, |
| 107 | + 'hand': state.get('hand', game.hands.get(current_pos, [])), |
| 108 | + 'bids': state['bids'], |
| 109 | + 'legal_bids': game.get_legal_bids(current_pos), |
| 110 | + 'dealer': state['dealer'], |
| 111 | + 'vulnerability': state['vulnerability'], |
| 112 | + } |
| 113 | + |
| 114 | + # Get bid from agent |
| 115 | + try: |
| 116 | + bid = agent_modules[current_pos].get_bid(agent_state) |
| 117 | + except Exception as e: |
| 118 | + self.logger.error(f"Simulation {idx}: Agent {agents[current_pos].name} error in get_bid: {e}") |
| 119 | + bid = "PASS" |
| 120 | + |
| 121 | + # Make bid |
| 122 | + if not game.make_bid(current_pos, bid): |
| 123 | + self.logger.warning( |
| 124 | + f"Simulation {idx}: Invalid bid '{bid}' from {agents[current_pos].name}, defaulting to PASS" |
| 125 | + ) |
| 126 | + game.make_bid(current_pos, "PASS") |
| 127 | + |
| 128 | + # Playing phase |
| 129 | + while game.phase == 'playing': |
| 130 | + current_pos = game.current_player |
| 131 | + state = game.get_state(current_pos) |
| 132 | + |
| 133 | + # Prepare game_state for agent |
| 134 | + agent_state = { |
| 135 | + 'position': current_pos, |
| 136 | + 'hand': state.get('hand', game.hands.get(current_pos, [])), |
| 137 | + 'current_trick': state['current_trick'], |
| 138 | + 'legal_cards': game.get_legal_cards(current_pos), |
| 139 | + 'contract': state['contract'], |
| 140 | + 'tricks_won': state['tricks_won'], |
| 141 | + } |
| 142 | + |
| 143 | + # Get card from agent |
| 144 | + try: |
| 145 | + card = agent_modules[current_pos].play_card(agent_state) |
| 146 | + except Exception as e: |
| 147 | + self.logger.error(f"Simulation {idx}: Agent {agents[current_pos].name} error in play_card: {e}") |
| 148 | + legal = game.get_legal_cards(current_pos) |
| 149 | + card = legal[0] if legal else "AS" |
| 150 | + |
| 151 | + # Play card |
| 152 | + if not game.play_card(current_pos, card): |
| 153 | + self.logger.warning( |
| 154 | + f"Simulation {idx}: Invalid card '{card}' from {agents[current_pos].name}, using first legal card" |
| 155 | + ) |
| 156 | + legal = game.get_legal_cards(current_pos) |
| 157 | + if legal: |
| 158 | + game.play_card(current_pos, legal[0]) |
| 159 | + |
| 160 | + # Save result to JSON log |
| 161 | + result = game.get_result() |
| 162 | + log_file = self.log_env / f"sim_{idx}.json" |
| 163 | + with open(log_file, 'w') as f: |
| 164 | + json.dump(result, f, indent=2) |
| 165 | + |
| 166 | + except Exception as e: |
| 167 | + self.logger.error(f"Simulation {idx} failed with error: {e}") |
| 168 | + finally: |
| 169 | + # Clean up sys.path |
| 170 | + if str(Path(self.environment.config.cwd) / "game_server") in sys.path: |
| 171 | + sys.path.remove(str(Path(self.environment.config.cwd) / "game_server")) |
| 172 | + |
| 173 | + def execute_round(self, agents: list[Player]): |
| 174 | + """Execute a round of Bridge games.""" |
| 175 | + sims = self.game_config['sims_per_round'] |
| 176 | + self.logger.info(f"Running {sims} Bridge simulations with 4 players") |
| 177 | + |
| 178 | + # Run simulations in parallel |
| 179 | + with ThreadPoolExecutor(max_workers=20) as executor: |
| 180 | + futures = [ |
| 181 | + executor.submit(self._run_single_simulation, agents, idx) |
| 182 | + for idx in range(sims) |
| 183 | + ] |
| 184 | + for future in tqdm(as_completed(futures), total=len(futures), desc="Bridge simulations"): |
| 185 | + future.result() |
| 186 | + |
| 187 | + def get_results(self, agents: list[Player], round_num: int, stats: RoundStats): |
| 188 | + """Parse results and determine winners.""" |
| 189 | + # Initialize team scores |
| 190 | + team_scores = {'NS': 0.0, 'EW': 0.0} |
| 191 | + games_played = 0 |
| 192 | + |
| 193 | + # Parse all simulation logs |
| 194 | + for idx in range(self.game_config['sims_per_round']): |
| 195 | + log_file = self.log_round(round_num) / f"sim_{idx}.json" |
| 196 | + |
| 197 | + if not log_file.exists(): |
| 198 | + self.logger.warning(f"Log file {log_file} not found, skipping") |
| 199 | + continue |
| 200 | + |
| 201 | + try: |
| 202 | + with open(log_file) as f: |
| 203 | + result = json.load(f) |
| 204 | + |
| 205 | + # Extract VP scores for each team |
| 206 | + vp_scores = result.get('normalized_score', {}) |
| 207 | + if vp_scores: |
| 208 | + team_scores['NS'] += vp_scores.get('NS', 0.0) |
| 209 | + team_scores['EW'] += vp_scores.get('EW', 0.0) |
| 210 | + games_played += 1 |
| 211 | + except (json.JSONDecodeError, KeyError) as e: |
| 212 | + self.logger.warning(f"Error parsing {log_file}: {e}") |
| 213 | + continue |
| 214 | + |
| 215 | + if games_played == 0: |
| 216 | + self.logger.error("No valid game results found") |
| 217 | + stats.winner = RESULT_TIE |
| 218 | + for agent in agents: |
| 219 | + stats.scores[agent.name] = 0.0 |
| 220 | + stats.player_stats[agent.name].score = 0.0 |
| 221 | + return |
| 222 | + |
| 223 | + # Average the scores |
| 224 | + team_scores['NS'] /= games_played |
| 225 | + team_scores['EW'] /= games_played |
| 226 | + |
| 227 | + # Determine winning team |
| 228 | + if abs(team_scores['NS'] - team_scores['EW']) < 0.01: # Tie threshold |
| 229 | + stats.winner = RESULT_TIE |
| 230 | + elif team_scores['NS'] > team_scores['EW']: |
| 231 | + stats.winner = f"{agents[0].name}/{agents[2].name}" |
| 232 | + else: |
| 233 | + stats.winner = f"{agents[1].name}/{agents[3].name}" |
| 234 | + |
| 235 | + # Assign scores to individual players based on their team |
| 236 | + for position, agent in enumerate(agents): |
| 237 | + team = 'NS' if position % 2 == 0 else 'EW' |
| 238 | + score = team_scores[team] |
| 239 | + stats.scores[agent.name] = score |
| 240 | + stats.player_stats[agent.name].score = score |
| 241 | + |
| 242 | + self.logger.info( |
| 243 | + f"Round {round_num} results - NS: {team_scores['NS']:.3f}, " |
| 244 | + f"EW: {team_scores['EW']:.3f}, Winner: {stats.winner}" |
| 245 | + ) |
0 commit comments