From 9b181cd0077cba4ffe03ce0ba8f1595ac5585754 Mon Sep 17 00:00:00 2001 From: muhtasham Date: Thu, 11 Dec 2025 19:42:30 +0100 Subject: [PATCH 1/4] add draft --- codeclash/arenas/__init__.py | 2 + codeclash/arenas/bridge/Bridge.Dockerfile | 17 ++ codeclash/arenas/bridge/__init__.py | 1 + codeclash/arenas/bridge/bridge.py | 245 ++++++++++++++++++ .../arenas/bridge/examples/random_agent.py | 62 +++++ .../arenas/bridge/game_server/__init__.py | 1 + codeclash/arenas/bridge/game_server/deck.py | 83 ++++++ codeclash/arenas/bridge/game_server/game.py | 210 +++++++++++++++ .../arenas/bridge/game_server/scoring.py | 129 +++++++++ configs/test/bridge.yaml | 36 +++ docs/reference/arenas/bridge.md | 106 ++++++++ mkdocs.yml | 1 + tests/arenas/test_bridge.py | 134 ++++++++++ 13 files changed, 1027 insertions(+) create mode 100644 codeclash/arenas/bridge/Bridge.Dockerfile create mode 100644 codeclash/arenas/bridge/__init__.py create mode 100644 codeclash/arenas/bridge/bridge.py create mode 100644 codeclash/arenas/bridge/examples/random_agent.py create mode 100644 codeclash/arenas/bridge/game_server/__init__.py create mode 100644 codeclash/arenas/bridge/game_server/deck.py create mode 100644 codeclash/arenas/bridge/game_server/game.py create mode 100644 codeclash/arenas/bridge/game_server/scoring.py create mode 100644 configs/test/bridge.yaml create mode 100644 docs/reference/arenas/bridge.md create mode 100644 tests/arenas/test_bridge.py diff --git a/codeclash/arenas/__init__.py b/codeclash/arenas/__init__.py index 7ffbb95f..04324097 100644 --- a/codeclash/arenas/__init__.py +++ b/codeclash/arenas/__init__.py @@ -1,6 +1,7 @@ from codeclash.arenas.arena import CodeArena from codeclash.arenas.battlecode.battlecode import BattleCodeArena from codeclash.arenas.battlesnake.battlesnake import BattleSnakeArena +from codeclash.arenas.bridge.bridge import BridgeArena from codeclash.arenas.corewar.corewar import CoreWarArena from codeclash.arenas.dummy.dummy import DummyArena from codeclash.arenas.halite.halite import HaliteArena @@ -13,6 +14,7 @@ ARENAS = [ BattleCodeArena, BattleSnakeArena, + BridgeArena, CoreWarArena, DummyArena, HaliteArena, diff --git a/codeclash/arenas/bridge/Bridge.Dockerfile b/codeclash/arenas/bridge/Bridge.Dockerfile new file mode 100644 index 00000000..7d3bb6df --- /dev/null +++ b/codeclash/arenas/bridge/Bridge.Dockerfile @@ -0,0 +1,17 @@ +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# Install Python 3.10 and basic tools +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + curl ca-certificates python3.10 python3.10-venv \ + python3-pip python-is-python3 wget git build-essential jq curl locales \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace + +# Copy Bridge game server code (game logic, deck management, scoring) +COPY codeclash/arenas/bridge/game_server /workspace/game_server + +# No additional dependencies needed - game logic is pure Python diff --git a/codeclash/arenas/bridge/__init__.py b/codeclash/arenas/bridge/__init__.py new file mode 100644 index 00000000..043d7d02 --- /dev/null +++ b/codeclash/arenas/bridge/__init__.py @@ -0,0 +1 @@ +"""Bridge arena for CodeClash.""" diff --git a/codeclash/arenas/bridge/bridge.py b/codeclash/arenas/bridge/bridge.py new file mode 100644 index 00000000..64407ed4 --- /dev/null +++ b/codeclash/arenas/bridge/bridge.py @@ -0,0 +1,245 @@ +"""Bridge Arena for CodeClash.""" + +import json +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +from tqdm.auto import tqdm + +from codeclash.agents.player import Player +from codeclash.arenas.arena import CodeArena, RoundStats +from codeclash.constants import RESULT_TIE + + +class BridgeArena(CodeArena): + name: str = "Bridge" + submission: str = "bridge_agent.py" + description: str = """Bridge is a 4-player trick-taking card game played in teams. + +Teams: North/South (positions 0/2) vs East/West (positions 1/3) + +Your bot (bridge_agent.py) must implement these functions: +- get_bid(game_state) -> str: Make bidding decisions, return bid string like "1H", "2NT", "PASS" +- play_card(game_state) -> str: Play a card, return card string like "AS", "7H" + +game_state is a dict containing: +- position: Your position (0=North, 1=East, 2=South, 3=West) +- hand: List of cards in your hand (e.g., ["AS", "KH", "7D"]) +- bids: List of previous bids +- legal_bids: List of legal bids you can make (during bidding) +- legal_cards: List of legal cards you can play (during playing) +- current_trick: Cards played so far in current trick +- contract: The current contract (if bidding is complete) +""" + default_args: dict = { + "sims_per_round": 10, + } + + def __init__(self, config, **kwargs): + # Validate player count before initializing (to avoid Docker build on invalid config) + num_players = len(config.get("players", [])) + if num_players != 4: + raise ValueError(f"Bridge requires exactly 4 players, got {num_players}") + super().__init__(config, **kwargs) + + def validate_code(self, agent: Player) -> tuple[bool, str | None]: + """Validate agent code has required functions.""" + if self.submission not in agent.environment.execute("ls")["output"]: + return False, f"No {self.submission} file found in root directory" + + content = agent.environment.execute(f"cat {self.submission}")["output"] + + # Check for required function definitions + required_functions = [ + "def get_bid(", + "def play_card(" + ] + + missing = [] + for func in required_functions: + if func not in content: + missing.append(func) + + if missing: + return False, f"Missing required functions: {', '.join(missing)}" + + return True, None + + def _run_single_simulation(self, agents: list[Player], idx: int): + """Run a single Bridge game simulation.""" + try: + # Import BridgeGame from game_server + sys.path.insert(0, str(Path(self.environment.config.cwd) / "game_server")) + from game import BridgeGame + + # Create game with random seed for reproducibility + game = BridgeGame(seed=idx, dealer=idx % 4) + + # Add players at positions 0-3 (North, East, South, West) + for position, agent in enumerate(agents): + game.add_player(position, agent.name) + + # Start game (deals cards) + if not game.start_game(): + self.logger.error(f"Simulation {idx}: Failed to start game") + return + + # Import agent modules dynamically + agent_modules = [] + for agent in agents: + agent_path = Path(agent.environment.config.cwd) / self.submission + spec = __import__( + 'importlib.util' + ).util.spec_from_file_location(f"agent_{agent.name}", agent_path) + module = __import__('importlib.util').util.module_from_spec(spec) + spec.loader.exec_module(module) + agent_modules.append(module) + + # Bidding phase + while game.phase == 'bidding': + current_pos = game.current_player + state = game.get_state(current_pos) + + # Prepare game_state for agent + agent_state = { + 'position': current_pos, + 'hand': state.get('hand', game.hands.get(current_pos, [])), + 'bids': state['bids'], + 'legal_bids': game.get_legal_bids(current_pos), + 'dealer': state['dealer'], + 'vulnerability': state['vulnerability'], + } + + # Get bid from agent + try: + bid = agent_modules[current_pos].get_bid(agent_state) + except Exception as e: + self.logger.error(f"Simulation {idx}: Agent {agents[current_pos].name} error in get_bid: {e}") + bid = "PASS" + + # Make bid + if not game.make_bid(current_pos, bid): + self.logger.warning( + f"Simulation {idx}: Invalid bid '{bid}' from {agents[current_pos].name}, defaulting to PASS" + ) + game.make_bid(current_pos, "PASS") + + # Playing phase + while game.phase == 'playing': + current_pos = game.current_player + state = game.get_state(current_pos) + + # Prepare game_state for agent + agent_state = { + 'position': current_pos, + 'hand': state.get('hand', game.hands.get(current_pos, [])), + 'current_trick': state['current_trick'], + 'legal_cards': game.get_legal_cards(current_pos), + 'contract': state['contract'], + 'tricks_won': state['tricks_won'], + } + + # Get card from agent + try: + card = agent_modules[current_pos].play_card(agent_state) + except Exception as e: + self.logger.error(f"Simulation {idx}: Agent {agents[current_pos].name} error in play_card: {e}") + legal = game.get_legal_cards(current_pos) + card = legal[0] if legal else "AS" + + # Play card + if not game.play_card(current_pos, card): + self.logger.warning( + f"Simulation {idx}: Invalid card '{card}' from {agents[current_pos].name}, using first legal card" + ) + legal = game.get_legal_cards(current_pos) + if legal: + game.play_card(current_pos, legal[0]) + + # Save result to JSON log + result = game.get_result() + log_file = self.log_env / f"sim_{idx}.json" + with open(log_file, 'w') as f: + json.dump(result, f, indent=2) + + except Exception as e: + self.logger.error(f"Simulation {idx} failed with error: {e}") + finally: + # Clean up sys.path + if str(Path(self.environment.config.cwd) / "game_server") in sys.path: + sys.path.remove(str(Path(self.environment.config.cwd) / "game_server")) + + def execute_round(self, agents: list[Player]): + """Execute a round of Bridge games.""" + sims = self.game_config['sims_per_round'] + self.logger.info(f"Running {sims} Bridge simulations with 4 players") + + # Run simulations in parallel + with ThreadPoolExecutor(max_workers=20) as executor: + futures = [ + executor.submit(self._run_single_simulation, agents, idx) + for idx in range(sims) + ] + for future in tqdm(as_completed(futures), total=len(futures), desc="Bridge simulations"): + future.result() + + def get_results(self, agents: list[Player], round_num: int, stats: RoundStats): + """Parse results and determine winners.""" + # Initialize team scores + team_scores = {'NS': 0.0, 'EW': 0.0} + games_played = 0 + + # Parse all simulation logs + for idx in range(self.game_config['sims_per_round']): + log_file = self.log_round(round_num) / f"sim_{idx}.json" + + if not log_file.exists(): + self.logger.warning(f"Log file {log_file} not found, skipping") + continue + + try: + with open(log_file) as f: + result = json.load(f) + + # Extract VP scores for each team + vp_scores = result.get('normalized_score', {}) + if vp_scores: + team_scores['NS'] += vp_scores.get('NS', 0.0) + team_scores['EW'] += vp_scores.get('EW', 0.0) + games_played += 1 + except (json.JSONDecodeError, KeyError) as e: + self.logger.warning(f"Error parsing {log_file}: {e}") + continue + + if games_played == 0: + self.logger.error("No valid game results found") + stats.winner = RESULT_TIE + for agent in agents: + stats.scores[agent.name] = 0.0 + stats.player_stats[agent.name].score = 0.0 + return + + # Average the scores + team_scores['NS'] /= games_played + team_scores['EW'] /= games_played + + # Determine winning team + if abs(team_scores['NS'] - team_scores['EW']) < 0.01: # Tie threshold + stats.winner = RESULT_TIE + elif team_scores['NS'] > team_scores['EW']: + stats.winner = f"{agents[0].name}/{agents[2].name}" + else: + stats.winner = f"{agents[1].name}/{agents[3].name}" + + # Assign scores to individual players based on their team + for position, agent in enumerate(agents): + team = 'NS' if position % 2 == 0 else 'EW' + score = team_scores[team] + stats.scores[agent.name] = score + stats.player_stats[agent.name].score = score + + self.logger.info( + f"Round {round_num} results - NS: {team_scores['NS']:.3f}, " + f"EW: {team_scores['EW']:.3f}, Winner: {stats.winner}" + ) diff --git a/codeclash/arenas/bridge/examples/random_agent.py b/codeclash/arenas/bridge/examples/random_agent.py new file mode 100644 index 00000000..0f85e996 --- /dev/null +++ b/codeclash/arenas/bridge/examples/random_agent.py @@ -0,0 +1,62 @@ +""" +Example random Bridge agent. + +This bot makes random legal moves for both bidding and playing. +Use this as a template for your own Bridge bot. +""" + +import random + + +def get_bid(game_state): + """ + Make a bidding decision. + + Args: + game_state: Dictionary containing: + - position: Your position (0=North, 1=East, 2=South, 3=West) + - hand: List of cards in your hand (e.g., ["AS", "KH", "7D"]) + - bids: List of previous bids + - legal_bids: List of legal bids you can make + + Returns: + A bid string (e.g., "PASS", "1H", "2NT", "3S") + """ + legal_bids = game_state.get("legal_bids", ["PASS"]) + + # Simple strategy: PASS 80% of the time, random bid 20% + if random.random() < 0.8 or len(legal_bids) == 1: + return "PASS" + + # Filter out PASS and choose a random bid + non_pass_bids = [b for b in legal_bids if b != "PASS"] + if non_pass_bids: + return random.choice(non_pass_bids) + return "PASS" + + +def play_card(game_state): + """ + Play a card. + + Args: + game_state: Dictionary containing: + - position: Your position (0=North, 1=East, 2=South, 3=West) + - hand: List of cards currently in your hand + - current_trick: List of cards played so far in current trick + - legal_cards: List of legal cards you can play + - contract: The current contract (level, suit, declarer) + - tricks_won: Tricks won by each team so far + + Returns: + A card string (e.g., "AS", "7H", "KD") + """ + legal_cards = game_state.get("legal_cards", game_state.get("hand", [])) + + if not legal_cards: + # Should never happen, but fallback to first card in hand + hand = game_state.get("hand", []) + return hand[0] if hand else "AS" + + # Play a random legal card + return random.choice(legal_cards) diff --git a/codeclash/arenas/bridge/game_server/__init__.py b/codeclash/arenas/bridge/game_server/__init__.py new file mode 100644 index 00000000..1da212cb --- /dev/null +++ b/codeclash/arenas/bridge/game_server/__init__.py @@ -0,0 +1 @@ +"""Bridge game server logic.""" diff --git a/codeclash/arenas/bridge/game_server/deck.py b/codeclash/arenas/bridge/game_server/deck.py new file mode 100644 index 00000000..50c0b7a7 --- /dev/null +++ b/codeclash/arenas/bridge/game_server/deck.py @@ -0,0 +1,83 @@ +"""Card deck management for Bridge.""" + +import random + +# Card representation: "AS" = Ace of Spades, "7H" = 7 of Hearts +SUITS = ['S', 'H', 'D', 'C'] # Spades, Hearts, Diamonds, Clubs +RANKS = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2'] + +# Rank values for comparison +RANK_VALUES = { + 'A': 14, 'K': 13, 'Q': 12, 'J': 11, 'T': 10, + '9': 9, '8': 8, '7': 7, '6': 6, '5': 5, '4': 4, '3': 3, '2': 2 +} + + +def create_deck() -> list[str]: + """Create a standard 52-card deck.""" + return [rank + suit for suit in SUITS for rank in RANKS] + + +def shuffle_and_deal(seed: int = None) -> dict[int, list[str]]: + """Shuffle deck and deal 13 cards to each of 4 players.""" + deck = create_deck() + if seed is not None: + random.seed(seed) + random.shuffle(deck) + + return { + 0: sorted(deck[0:13], key=lambda c: (SUITS.index(c[1]), -RANK_VALUES[c[0]])), + 1: sorted(deck[13:26], key=lambda c: (SUITS.index(c[1]), -RANK_VALUES[c[0]])), + 2: sorted(deck[26:39], key=lambda c: (SUITS.index(c[1]), -RANK_VALUES[c[0]])), + 3: sorted(deck[39:52], key=lambda c: (SUITS.index(c[1]), -RANK_VALUES[c[0]])), + } + + +def get_suit(card: str) -> str: + """Extract suit from card.""" + return card[1] + + +def get_rank(card: str) -> str: + """Extract rank from card.""" + return card[0] + + +def compare_cards(card1: str, card2: str, trump_suit: str | None, lead_suit: str) -> int: + """ + Compare two cards to determine winner. + + Returns: + 1 if card1 wins, -1 if card2 wins, 0 if equal (shouldn't happen) + """ + suit1, rank1 = get_suit(card1), get_rank(card1) + suit2, rank2 = get_suit(card2), get_rank(card2) + + # Trump beats non-trump + if trump_suit: + if suit1 == trump_suit and suit2 != trump_suit: + return 1 + if suit2 == trump_suit and suit1 != trump_suit: + return -1 + + # Must follow suit - if one follows and one doesn't, follower wins + if suit1 == lead_suit and suit2 != lead_suit: + return 1 + if suit2 == lead_suit and suit1 != lead_suit: + return -1 + + # Both same suit (or both trump) - compare ranks + if RANK_VALUES[rank1] > RANK_VALUES[rank2]: + return 1 + if RANK_VALUES[rank1] < RANK_VALUES[rank2]: + return -1 + + return 0 + + +def is_valid_card(card: str) -> bool: + """Check if card string is valid.""" + if len(card) != 2: + return False + rank, suit = card[0], card[1] + return rank in RANKS and suit in SUITS diff --git a/codeclash/arenas/bridge/game_server/game.py b/codeclash/arenas/bridge/game_server/game.py new file mode 100644 index 00000000..10defba6 --- /dev/null +++ b/codeclash/arenas/bridge/game_server/game.py @@ -0,0 +1,210 @@ +"""Core Bridge game logic.""" +import uuid + +from .deck import compare_cards, get_suit, shuffle_and_deal +from .scoring import calculate_contract_score, get_declarer_team, normalize_to_vp + + +class BridgeGame: + """ + Bridge game state manager. + + Manages a complete Bridge game from bidding through playing to scoring. + Handles 4 players in fixed partnerships (NS vs EW). + """ + + def __init__(self, seed: int = None, dealer: int = 0, vulnerability: dict[str, bool] = None): + self.game_id = str(uuid.uuid4())[:8] + self.phase = 'waiting' + self.players = {} + self.hands = {} + self.dealer = dealer + self.bids = [] + self.contract = None + self.current_trick = [] + self.tricks_won = {'NS': 0, 'EW': 0} + self.played_tricks = [] + self.current_player = None + self.vulnerability = vulnerability or {'NS': False, 'EW': False} + self.raw_score = {'NS': 0, 'EW': 0} + self.vp_score = {'NS': 0.0, 'EW': 0.0} + if seed is not None: + self.hands = shuffle_and_deal(seed) + + def add_player(self, position: int, name: str) -> bool: + if position not in [0, 1, 2, 3] or position in self.players: + return False + self.players[position] = name + return True + + def start_game(self) -> bool: + if len(self.players) != 4: + return False + if not self.hands: + self.hands = shuffle_and_deal() + self.phase = 'bidding' + self.current_player = self.dealer + return True + + def get_legal_bids(self, position: int) -> list[str]: + if self.phase != 'bidding' or position != self.current_player: + return [] + legal = ['PASS'] + highest_level = 0 + highest_suit_index = -1 + suit_order = ['C', 'D', 'H', 'S', 'NT'] + for bid_record in self.bids: + bid = bid_record['bid'] + if bid not in ['PASS', 'DOUBLE', 'REDOUBLE']: + level = int(bid[0]) + suit = bid[1:] if len(bid) > 1 else bid[1] + if level > highest_level or (level == highest_level and suit_order.index(suit) > highest_suit_index): + highest_level = level + highest_suit_index = suit_order.index(suit) + for level in range(1, 8): + for suit in suit_order: + if level > highest_level or (level == highest_level and suit_order.index(suit) > highest_suit_index): + legal.append(f"{level}{suit}") + return legal + + def make_bid(self, position: int, bid: str) -> bool: + if self.phase != 'bidding' or position != self.current_player: + return False + if bid not in self.get_legal_bids(position): + return False + self.bids.append({'position': position, 'bid': bid}) + if len(self.bids) >= 4: + last_three = [b['bid'] for b in self.bids[-3:]] + if all(b == 'PASS' for b in last_three): + self._finalize_contract() + if self.contract: + self.phase = 'playing' + self.current_player = (self.contract['declarer'] + 1) % 4 + else: + self.phase = 'finished' + self.raw_score = {'NS': 0, 'EW': 0} + self.vp_score = {'NS': 0.5, 'EW': 0.5} + return True + self.current_player = (self.current_player + 1) % 4 + return True + + def _finalize_contract(self): + contract_bids = [b for b in self.bids if b['bid'] not in ['PASS', 'DOUBLE', 'REDOUBLE']] + if not contract_bids: + self.contract = None + return + last_bid = contract_bids[-1] + bid_str = last_bid['bid'] + level = int(bid_str[0]) + suit = bid_str[1:] + bid_team = 'NS' if last_bid['position'] % 2 == 0 else 'EW' + declarer = None + for bid_record in self.bids: + if bid_record['bid'] not in ['PASS', 'DOUBLE', 'REDOUBLE']: + bid_suit = bid_record['bid'][1:] + team = 'NS' if bid_record['position'] % 2 == 0 else 'EW' + if bid_suit == suit and team == bid_team: + declarer = bid_record['position'] + break + self.contract = {'level': level, 'suit': suit, 'declarer': declarer, 'doubled': False, 'redoubled': False} + + def get_legal_cards(self, position: int) -> list[str]: + if self.phase != 'playing' or position != self.current_player: + return [] + hand = self.hands.get(position, []) + if not self.current_trick: + return hand[:] + lead_suit = get_suit(self.current_trick[0]['card']) + cards_in_suit = [c for c in hand if get_suit(c) == lead_suit] + return cards_in_suit if cards_in_suit else hand[:] + + def play_card(self, position: int, card: str) -> bool: + if self.phase != 'playing' or position != self.current_player: + return False + if card not in self.get_legal_cards(position): + return False + self.hands[position].remove(card) + self.current_trick.append({'position': position, 'card': card}) + if len(self.current_trick) == 4: + self._complete_trick() + if all(len(hand) == 0 for hand in self.hands.values()): + self._finalize_score() + self.phase = 'finished' + else: + if len(self.current_trick) > 0: + self.current_player = (self.current_player + 1) % 4 + return True + + def _complete_trick(self): + if len(self.current_trick) != 4: + return + trump_suit = self.contract['suit'] if self.contract['suit'] != 'NT' else None + lead_suit = get_suit(self.current_trick[0]['card']) + winner_idx = 0 + winner_card = self.current_trick[0]['card'] + for i in range(1, 4): + card = self.current_trick[i]['card'] + if compare_cards(card, winner_card, trump_suit, lead_suit) > 0: + winner_idx = i + winner_card = card + winner_position = self.current_trick[winner_idx]['position'] + winner_team = 'NS' if winner_position % 2 == 0 else 'EW' + self.tricks_won[winner_team] += 1 + self.played_tricks.append(self.current_trick[:]) + self.current_trick = [] + self.current_player = winner_position + + def _finalize_score(self): + if not self.contract: + self.raw_score = {'NS': 0, 'EW': 0} + self.vp_score = {'NS': 0.5, 'EW': 0.5} + return + declarer_team = get_declarer_team(self.contract['declarer']) + tricks_made = self.tricks_won[declarer_team] + ns_raw, ew_raw = calculate_contract_score( + level=self.contract['level'], + suit=self.contract['suit'], + declarer_team=declarer_team, + tricks_made=tricks_made, + doubled=self.contract['doubled'], + redoubled=self.contract['redoubled'], + vulnerable=self.vulnerability + ) + self.raw_score = {'NS': ns_raw, 'EW': ew_raw} + self.vp_score = normalize_to_vp(ns_raw, ew_raw) + + def get_state(self, position: int = None) -> dict: + state = { + 'game_id': self.game_id, + 'phase': self.phase, + 'dealer': self.dealer, + 'vulnerability': self.vulnerability, + 'players': dict(self.players), + 'current_player': self.current_player + } + state['bids'] = self.bids[:] + state['contract'] = self.contract + if self.phase in ['playing', 'finished']: + state['current_trick'] = self.current_trick[:] + state['tricks_won'] = dict(self.tricks_won) + if position is not None: + state['hand'] = self.hands.get(position, []) + else: + state['all_hands'] = self.hands + if self.phase == 'finished': + state['raw_score'] = self.raw_score + state['vp_score'] = self.vp_score + return state + + def get_result(self) -> dict: + if self.phase != 'finished': + return {} + return { + 'game_id': self.game_id, + 'contract': self.contract, + 'tricks_won': self.tricks_won, + 'raw_score': self.raw_score, + 'normalized_score': self.vp_score, + 'bids': self.bids, + 'played_tricks': self.played_tricks + } diff --git a/codeclash/arenas/bridge/game_server/scoring.py b/codeclash/arenas/bridge/game_server/scoring.py new file mode 100644 index 00000000..0669fa1c --- /dev/null +++ b/codeclash/arenas/bridge/game_server/scoring.py @@ -0,0 +1,129 @@ +"""Bridge scoring rules.""" + + +def calculate_contract_score( + level: int, + suit: str, + declarer_team: str, + tricks_made: int, + doubled: bool, + redoubled: bool, + vulnerable: dict[str, bool] +) -> tuple[int, int]: + """Calculate the score for a Bridge contract.""" + tricks_needed = 6 + level + is_vulnerable = vulnerable.get(declarer_team, False) + + if tricks_made >= tricks_needed: + score = _calculate_made_contract( + level, suit, tricks_made, tricks_needed, + doubled, redoubled, is_vulnerable + ) + else: + undertricks = tricks_needed - tricks_made + score = -_calculate_undertrick_penalty( + undertricks, doubled, redoubled, is_vulnerable + ) + + if declarer_team == 'NS': + return (score, -score) + else: + return (-score, score) + + +def _calculate_made_contract( + level: int, suit: str, tricks_made: int, tricks_needed: int, + doubled: bool, redoubled: bool, vulnerable: bool +) -> int: + """Calculate points for making a contract.""" + if suit in ['C', 'D']: + base_per_trick = 20 + elif suit in ['H', 'S']: + base_per_trick = 30 + else: # NT + base_per_trick = 30 + + trick_points = base_per_trick * level + if suit == 'NT': + trick_points += 10 + + if redoubled: + trick_points *= 4 + elif doubled: + trick_points *= 2 + + overtricks = tricks_made - tricks_needed + overtrick_points = 0 + + if overtricks > 0: + if doubled or redoubled: + per_overtrick = 100 if vulnerable else 50 + if redoubled: + per_overtrick *= 2 + overtrick_points = per_overtrick * overtricks + else: + overtrick_points = base_per_trick * overtricks + + bonus = 0 + if trick_points >= 100: + bonus += 500 if vulnerable else 300 + else: + bonus += 50 + + if level == 6 and tricks_made >= 12: + bonus += 750 if vulnerable else 500 + + if level == 7 and tricks_made == 13: + bonus += 1500 if vulnerable else 1000 + + if redoubled: + bonus += 100 + elif doubled: + bonus += 50 + + return trick_points + overtrick_points + bonus + + +def _calculate_undertrick_penalty( + undertricks: int, doubled: bool, redoubled: bool, vulnerable: bool +) -> int: + """Calculate penalty for failing to make contract.""" + if not doubled and not redoubled: + per_undertrick = 100 if vulnerable else 50 + return per_undertrick * undertricks + + penalties = [] + for i in range(undertricks): + if i == 0: + penalty = 200 if vulnerable else 100 + elif i < 3: + penalty = 300 if vulnerable else 200 + else: + penalty = 300 + + if redoubled: + penalty *= 2 + + penalties.append(penalty) + + return sum(penalties) + + +def normalize_to_vp(ns_raw: int, ew_raw: int) -> dict[str, float]: + """Normalize raw scores to Victory Points (VP) on 0-1 scale.""" + diff = ns_raw - ew_raw + imps = diff / 30.0 + vp_diff = max(-1.0, min(1.0, imps / 10.0)) + + ns_vp = 0.5 + vp_diff / 2 + ew_vp = 0.5 - vp_diff / 2 + + return { + 'NS': round(ns_vp, 3), + 'EW': round(ew_vp, 3) + } + + +def get_declarer_team(declarer_position: int) -> str: + """Get team name from declarer position.""" + return 'NS' if declarer_position % 2 == 0 else 'EW' diff --git a/configs/test/bridge.yaml b/configs/test/bridge.yaml new file mode 100644 index 00000000..c1e5e6ed --- /dev/null +++ b/configs/test/bridge.yaml @@ -0,0 +1,36 @@ +tournament: + rounds: 3 +game: + name: Bridge + sims_per_round: 10 +players: + - agent: dummy + name: north + - agent: dummy + name: east + - agent: dummy + name: south + - agent: dummy + name: west +prompts: + game_description: | + You are a software developer ({{player_id}}) competing in a Bridge coding game. + + Bridge is a 4-player trick-taking card game played in teams: + - North/South (positions 0/2) vs East/West (positions 1/3) + + The game is played in {{rounds}} rounds. For every round, you edit your bot code (bridge_agent.py). + After all players finish editing, games are run automatically. This is round {{round}}. + + Your bot must implement two functions: + - get_bid(game_state) -> str: Return bid like "1H", "2NT", "PASS" + - play_card(game_state) -> str: Return card like "AS", "7H" + + The game_state dict contains: + - position: Your position (0=North, 1=East, 2=South, 3=West) + - hand: List of cards in your hand + - bids: List of previous bids + - legal_bids: Legal bids you can make (during bidding) + - legal_cards: Legal cards you can play (during playing) + - current_trick: Cards played so far in current trick + - contract: The current contract (if bidding is complete) diff --git a/docs/reference/arenas/bridge.md b/docs/reference/arenas/bridge.md new file mode 100644 index 00000000..101ac2f1 --- /dev/null +++ b/docs/reference/arenas/bridge.md @@ -0,0 +1,106 @@ +# Bridge + +4-player trick-taking card game played in teams. + +## Overview + +Bridge is a classic card game where North/South compete against East/West. Players bid to determine the contract, then play 13 tricks to fulfill or defeat it. The game combines strategic bidding with tactical card play. + +## Implementation + +::: codeclash.arenas.bridge.bridge.BridgeArena + options: + show_root_heading: true + heading_level: 2 + +## Agent Interface + +Your bot must be a Python file (`bridge_agent.py`) implementing two functions: + +### get_bid(game_state) + +Make a bidding decision during the bidding phase. + +**Parameters:** +- `game_state` (dict): Current game state including: + - `position`: Your position (0=North, 1=East, 2=South, 3=West) + - `hand`: List of cards in your hand (e.g., `["AS", "KH", "7D"]`) + - `bids`: List of previous bids + - `legal_bids`: List of legal bids you can make + +**Returns:** +- `str`: Bid string like `"1H"`, `"2NT"`, `"3S"`, or `"PASS"` + +### play_card(game_state) + +Play a card during the playing phase. + +**Parameters:** +- `game_state` (dict): Current game state including: + - `position`: Your position + - `hand`: Cards currently in your hand + - `current_trick`: Cards played so far in current trick + - `legal_cards`: Legal cards you can play + - `contract`: The current contract (level, suit, declarer) + - `tricks_won`: Tricks won by each team + +**Returns:** +- `str`: Card string like `"AS"`, `"7H"`, `"KD"` + +## Example Agent + +```python +import random + +def get_bid(game_state): + """Simple strategy: PASS 80% of the time.""" + legal_bids = game_state.get("legal_bids", ["PASS"]) + + if random.random() < 0.8 or len(legal_bids) == 1: + return "PASS" + + non_pass_bids = [b for b in legal_bids if b != "PASS"] + return random.choice(non_pass_bids) if non_pass_bids else "PASS" + +def play_card(game_state): + """Play a random legal card.""" + legal_cards = game_state.get("legal_cards", game_state.get("hand", [])) + return random.choice(legal_cards) if legal_cards else "AS" +``` + +## Configuration Example + +```yaml +tournament: + rounds: 3 +game: + name: Bridge + sims_per_round: 10 +players: + - agent: dummy + name: north + - agent: dummy + name: east + - agent: dummy + name: south + - agent: dummy + name: west +``` + +## Teams + +Bridge is played in fixed partnerships: +- **North/South (NS)**: Positions 0 and 2 +- **East/West (EW)**: Positions 1 and 3 + +Scores are calculated per team using Victory Points (VP) normalized to 0-1 scale. + +## Scoring + +The game uses standard Contract Bridge scoring: +- Contract made: Base points + overtricks + game/slam bonuses +- Contract failed: Undertrick penalties +- Vulnerability affects bonuses and penalties +- Raw scores are converted to Victory Points (VP) + +--8<-- "docs/_footer.md" diff --git a/mkdocs.yml b/mkdocs.yml index e906978c..605dc70d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,6 +72,7 @@ nav: - "CodeGame (Abstract)": "reference/arenas/game.md" - "BattleCode": "reference/arenas/battlecode.md" - "BattleSnake": "reference/arenas/battlesnake.md" + - "Bridge": "reference/arenas/bridge.md" - "CoreWar": "reference/arenas/corewar.md" - "Halite": "reference/arenas/halite.md" - "Halite II": "reference/arenas/halite2.md" diff --git a/tests/arenas/test_bridge.py b/tests/arenas/test_bridge.py new file mode 100644 index 00000000..4938f440 --- /dev/null +++ b/tests/arenas/test_bridge.py @@ -0,0 +1,134 @@ +"""Unit tests for BridgeArena.""" + +import pytest + +from codeclash.arenas.bridge.bridge import BridgeArena + +VALID_BRIDGE_BOT = """ +def get_bid(game_state): + '''Make a bidding decision based on game state.''' + # Simple strategy: always pass + return "PASS" + +def play_card(game_state): + '''Play a card based on game state.''' + # Simple strategy: play first legal card + legal_cards = game_state.get('legal_cards', game_state.get('hand', [])) + if legal_cards: + return legal_cards[0] + return "AS" +""" + + +class TestBridgeValidation: + """Tests for BridgeArena.validate_code()""" + + @pytest.fixture + def arena(self, tmp_log_dir, minimal_config): + """Create BridgeArena instance with mocked environment.""" + config = minimal_config.copy() + config["game"]["name"] = "Bridge" + config["players"] = [ + {"name": "north", "agent": "dummy"}, + {"name": "east", "agent": "dummy"}, + {"name": "south", "agent": "dummy"}, + {"name": "west", "agent": "dummy"}, + ] + arena = BridgeArena.__new__(BridgeArena) + arena.submission = "bridge_agent.py" + arena.log_local = tmp_log_dir + return arena + + def test_valid_submission(self, arena, mock_player_factory): + """Test that a valid Bridge bot passes validation.""" + player = mock_player_factory( + name="test_player", + files={"bridge_agent.py": VALID_BRIDGE_BOT}, + command_outputs={ + "ls": {"output": "bridge_agent.py\n", "returncode": 0}, + "cat bridge_agent.py": {"output": VALID_BRIDGE_BOT, "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is True + assert error is None + + def test_missing_file(self, arena, mock_player_factory): + """Test that missing bridge_agent.py fails validation.""" + player = mock_player_factory( + name="test_player", + files={}, + command_outputs={ + "ls": {"output": "other.py\n", "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "bridge_agent.py" in error + + def test_missing_bid_function(self, arena, mock_player_factory): + """Test that missing get_bid function fails validation.""" + bot_code = """ +def play_card(game_state): + '''Play a card.''' + return "AS" +""" + player = mock_player_factory( + name="test_player", + files={"bridge_agent.py": bot_code}, + command_outputs={ + "ls": {"output": "bridge_agent.py\n", "returncode": 0}, + "cat bridge_agent.py": {"output": bot_code, "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "def get_bid(" in error + + def test_missing_play_function(self, arena, mock_player_factory): + """Test that missing play_card function fails validation.""" + bot_code = """ +def get_bid(game_state): + '''Make a bid.''' + return "PASS" +""" + player = mock_player_factory( + name="test_player", + files={"bridge_agent.py": bot_code}, + command_outputs={ + "ls": {"output": "bridge_agent.py\n", "returncode": 0}, + "cat bridge_agent.py": {"output": bot_code, "returncode": 0}, + }, + ) + is_valid, error = arena.validate_code(player) + assert is_valid is False + assert "def play_card(" in error + + + +class TestBridgeRequirements: + """Test Bridge-specific requirements.""" + + def test_requires_4_players(self, minimal_config, tmp_log_dir): + """Test that Bridge requires exactly 4 players.""" + config = minimal_config.copy() + config["game"]["name"] = "Bridge" + config["players"] = [ + {"name": "p1", "agent": "dummy"}, + {"name": "p2", "agent": "dummy"}, + ] + + with pytest.raises(ValueError, match="Bridge requires exactly 4 players"): + BridgeArena( + config, + tournament_id="test_tournament", + local_output_dir=tmp_log_dir + ) + + def test_accepts_4_players(self): + """Test that Bridge accepts exactly 4 players by checking class properties.""" + # Since we validated that ValueError is raised for wrong player count, + # we can trust that 4 players will be accepted + # Test class attributes instead of full initialization (avoids Docker requirement) + assert BridgeArena.name == "Bridge" + assert BridgeArena.submission == "bridge_agent.py" From a9e7aeaabacafe0361fb10719b2adbf59afa0b34 Mon Sep 17 00:00:00 2001 From: muhtasham Date: Thu, 11 Dec 2025 19:53:19 +0100 Subject: [PATCH 2/4] fix imports --- codeclash/arenas/bridge/game_server/game.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codeclash/arenas/bridge/game_server/game.py b/codeclash/arenas/bridge/game_server/game.py index 10defba6..b827f114 100644 --- a/codeclash/arenas/bridge/game_server/game.py +++ b/codeclash/arenas/bridge/game_server/game.py @@ -1,8 +1,8 @@ """Core Bridge game logic.""" import uuid -from .deck import compare_cards, get_suit, shuffle_and_deal -from .scoring import calculate_contract_score, get_declarer_team, normalize_to_vp +from deck import compare_cards, get_suit, shuffle_and_deal +from scoring import calculate_contract_score, get_declarer_team, normalize_to_vp class BridgeGame: From c94b859afc5cf718a3d80fb3ec1ecb1092d9be5e Mon Sep 17 00:00:00 2001 From: muhtasham Date: Thu, 11 Dec 2025 21:23:08 +0100 Subject: [PATCH 3/4] Move Bridge game server to separate CodeClash-ai/Bridge repo - Update Dockerfile to clone from https://github.com/CodeClash-ai/Bridge - Remove game_server/ and examples/ (now in separate repo) - Update bridge.py path references --- codeclash/arenas/bridge/Bridge.Dockerfile | 7 +- codeclash/arenas/bridge/bridge.py | 9 +- .../arenas/bridge/examples/random_agent.py | 62 ------ .../arenas/bridge/game_server/__init__.py | 1 - codeclash/arenas/bridge/game_server/deck.py | 83 ------- codeclash/arenas/bridge/game_server/game.py | 210 ------------------ .../arenas/bridge/game_server/scoring.py | 129 ----------- 7 files changed, 9 insertions(+), 492 deletions(-) delete mode 100644 codeclash/arenas/bridge/examples/random_agent.py delete mode 100644 codeclash/arenas/bridge/game_server/__init__.py delete mode 100644 codeclash/arenas/bridge/game_server/deck.py delete mode 100644 codeclash/arenas/bridge/game_server/game.py delete mode 100644 codeclash/arenas/bridge/game_server/scoring.py diff --git a/codeclash/arenas/bridge/Bridge.Dockerfile b/codeclash/arenas/bridge/Bridge.Dockerfile index 7d3bb6df..7629fbc1 100644 --- a/codeclash/arenas/bridge/Bridge.Dockerfile +++ b/codeclash/arenas/bridge/Bridge.Dockerfile @@ -9,9 +9,10 @@ RUN apt-get update \ python3-pip python-is-python3 wget git build-essential jq curl locales \ && rm -rf /var/lib/apt/lists/* -WORKDIR /workspace +RUN git clone https://github.com/CodeClash-ai/Bridge.git /workspace \ + && cd /workspace \ + && git remote set-url origin https://github.com/CodeClash-ai/Bridge.git -# Copy Bridge game server code (game logic, deck management, scoring) -COPY codeclash/arenas/bridge/game_server /workspace/game_server +WORKDIR /workspace # No additional dependencies needed - game logic is pure Python diff --git a/codeclash/arenas/bridge/bridge.py b/codeclash/arenas/bridge/bridge.py index 64407ed4..bea00052 100644 --- a/codeclash/arenas/bridge/bridge.py +++ b/codeclash/arenas/bridge/bridge.py @@ -68,9 +68,10 @@ def validate_code(self, agent: Player) -> tuple[bool, str | None]: def _run_single_simulation(self, agents: list[Player], idx: int): """Run a single Bridge game simulation.""" + game_server_path = str(Path(self.environment.config.cwd) / "game_server") try: - # Import BridgeGame from game_server - sys.path.insert(0, str(Path(self.environment.config.cwd) / "game_server")) + # Import BridgeGame from game_server (cloned from CodeClash-ai/Bridge repo) + sys.path.insert(0, game_server_path) from game import BridgeGame # Create game with random seed for reproducibility @@ -167,8 +168,8 @@ def _run_single_simulation(self, agents: list[Player], idx: int): self.logger.error(f"Simulation {idx} failed with error: {e}") finally: # Clean up sys.path - if str(Path(self.environment.config.cwd) / "game_server") in sys.path: - sys.path.remove(str(Path(self.environment.config.cwd) / "game_server")) + if game_server_path in sys.path: + sys.path.remove(game_server_path) def execute_round(self, agents: list[Player]): """Execute a round of Bridge games.""" diff --git a/codeclash/arenas/bridge/examples/random_agent.py b/codeclash/arenas/bridge/examples/random_agent.py deleted file mode 100644 index 0f85e996..00000000 --- a/codeclash/arenas/bridge/examples/random_agent.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -Example random Bridge agent. - -This bot makes random legal moves for both bidding and playing. -Use this as a template for your own Bridge bot. -""" - -import random - - -def get_bid(game_state): - """ - Make a bidding decision. - - Args: - game_state: Dictionary containing: - - position: Your position (0=North, 1=East, 2=South, 3=West) - - hand: List of cards in your hand (e.g., ["AS", "KH", "7D"]) - - bids: List of previous bids - - legal_bids: List of legal bids you can make - - Returns: - A bid string (e.g., "PASS", "1H", "2NT", "3S") - """ - legal_bids = game_state.get("legal_bids", ["PASS"]) - - # Simple strategy: PASS 80% of the time, random bid 20% - if random.random() < 0.8 or len(legal_bids) == 1: - return "PASS" - - # Filter out PASS and choose a random bid - non_pass_bids = [b for b in legal_bids if b != "PASS"] - if non_pass_bids: - return random.choice(non_pass_bids) - return "PASS" - - -def play_card(game_state): - """ - Play a card. - - Args: - game_state: Dictionary containing: - - position: Your position (0=North, 1=East, 2=South, 3=West) - - hand: List of cards currently in your hand - - current_trick: List of cards played so far in current trick - - legal_cards: List of legal cards you can play - - contract: The current contract (level, suit, declarer) - - tricks_won: Tricks won by each team so far - - Returns: - A card string (e.g., "AS", "7H", "KD") - """ - legal_cards = game_state.get("legal_cards", game_state.get("hand", [])) - - if not legal_cards: - # Should never happen, but fallback to first card in hand - hand = game_state.get("hand", []) - return hand[0] if hand else "AS" - - # Play a random legal card - return random.choice(legal_cards) diff --git a/codeclash/arenas/bridge/game_server/__init__.py b/codeclash/arenas/bridge/game_server/__init__.py deleted file mode 100644 index 1da212cb..00000000 --- a/codeclash/arenas/bridge/game_server/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Bridge game server logic.""" diff --git a/codeclash/arenas/bridge/game_server/deck.py b/codeclash/arenas/bridge/game_server/deck.py deleted file mode 100644 index 50c0b7a7..00000000 --- a/codeclash/arenas/bridge/game_server/deck.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Card deck management for Bridge.""" - -import random - -# Card representation: "AS" = Ace of Spades, "7H" = 7 of Hearts -SUITS = ['S', 'H', 'D', 'C'] # Spades, Hearts, Diamonds, Clubs -RANKS = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2'] - -# Rank values for comparison -RANK_VALUES = { - 'A': 14, 'K': 13, 'Q': 12, 'J': 11, 'T': 10, - '9': 9, '8': 8, '7': 7, '6': 6, '5': 5, '4': 4, '3': 3, '2': 2 -} - - -def create_deck() -> list[str]: - """Create a standard 52-card deck.""" - return [rank + suit for suit in SUITS for rank in RANKS] - - -def shuffle_and_deal(seed: int = None) -> dict[int, list[str]]: - """Shuffle deck and deal 13 cards to each of 4 players.""" - deck = create_deck() - if seed is not None: - random.seed(seed) - random.shuffle(deck) - - return { - 0: sorted(deck[0:13], key=lambda c: (SUITS.index(c[1]), -RANK_VALUES[c[0]])), - 1: sorted(deck[13:26], key=lambda c: (SUITS.index(c[1]), -RANK_VALUES[c[0]])), - 2: sorted(deck[26:39], key=lambda c: (SUITS.index(c[1]), -RANK_VALUES[c[0]])), - 3: sorted(deck[39:52], key=lambda c: (SUITS.index(c[1]), -RANK_VALUES[c[0]])), - } - - -def get_suit(card: str) -> str: - """Extract suit from card.""" - return card[1] - - -def get_rank(card: str) -> str: - """Extract rank from card.""" - return card[0] - - -def compare_cards(card1: str, card2: str, trump_suit: str | None, lead_suit: str) -> int: - """ - Compare two cards to determine winner. - - Returns: - 1 if card1 wins, -1 if card2 wins, 0 if equal (shouldn't happen) - """ - suit1, rank1 = get_suit(card1), get_rank(card1) - suit2, rank2 = get_suit(card2), get_rank(card2) - - # Trump beats non-trump - if trump_suit: - if suit1 == trump_suit and suit2 != trump_suit: - return 1 - if suit2 == trump_suit and suit1 != trump_suit: - return -1 - - # Must follow suit - if one follows and one doesn't, follower wins - if suit1 == lead_suit and suit2 != lead_suit: - return 1 - if suit2 == lead_suit and suit1 != lead_suit: - return -1 - - # Both same suit (or both trump) - compare ranks - if RANK_VALUES[rank1] > RANK_VALUES[rank2]: - return 1 - if RANK_VALUES[rank1] < RANK_VALUES[rank2]: - return -1 - - return 0 - - -def is_valid_card(card: str) -> bool: - """Check if card string is valid.""" - if len(card) != 2: - return False - rank, suit = card[0], card[1] - return rank in RANKS and suit in SUITS diff --git a/codeclash/arenas/bridge/game_server/game.py b/codeclash/arenas/bridge/game_server/game.py deleted file mode 100644 index b827f114..00000000 --- a/codeclash/arenas/bridge/game_server/game.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Core Bridge game logic.""" -import uuid - -from deck import compare_cards, get_suit, shuffle_and_deal -from scoring import calculate_contract_score, get_declarer_team, normalize_to_vp - - -class BridgeGame: - """ - Bridge game state manager. - - Manages a complete Bridge game from bidding through playing to scoring. - Handles 4 players in fixed partnerships (NS vs EW). - """ - - def __init__(self, seed: int = None, dealer: int = 0, vulnerability: dict[str, bool] = None): - self.game_id = str(uuid.uuid4())[:8] - self.phase = 'waiting' - self.players = {} - self.hands = {} - self.dealer = dealer - self.bids = [] - self.contract = None - self.current_trick = [] - self.tricks_won = {'NS': 0, 'EW': 0} - self.played_tricks = [] - self.current_player = None - self.vulnerability = vulnerability or {'NS': False, 'EW': False} - self.raw_score = {'NS': 0, 'EW': 0} - self.vp_score = {'NS': 0.0, 'EW': 0.0} - if seed is not None: - self.hands = shuffle_and_deal(seed) - - def add_player(self, position: int, name: str) -> bool: - if position not in [0, 1, 2, 3] or position in self.players: - return False - self.players[position] = name - return True - - def start_game(self) -> bool: - if len(self.players) != 4: - return False - if not self.hands: - self.hands = shuffle_and_deal() - self.phase = 'bidding' - self.current_player = self.dealer - return True - - def get_legal_bids(self, position: int) -> list[str]: - if self.phase != 'bidding' or position != self.current_player: - return [] - legal = ['PASS'] - highest_level = 0 - highest_suit_index = -1 - suit_order = ['C', 'D', 'H', 'S', 'NT'] - for bid_record in self.bids: - bid = bid_record['bid'] - if bid not in ['PASS', 'DOUBLE', 'REDOUBLE']: - level = int(bid[0]) - suit = bid[1:] if len(bid) > 1 else bid[1] - if level > highest_level or (level == highest_level and suit_order.index(suit) > highest_suit_index): - highest_level = level - highest_suit_index = suit_order.index(suit) - for level in range(1, 8): - for suit in suit_order: - if level > highest_level or (level == highest_level and suit_order.index(suit) > highest_suit_index): - legal.append(f"{level}{suit}") - return legal - - def make_bid(self, position: int, bid: str) -> bool: - if self.phase != 'bidding' or position != self.current_player: - return False - if bid not in self.get_legal_bids(position): - return False - self.bids.append({'position': position, 'bid': bid}) - if len(self.bids) >= 4: - last_three = [b['bid'] for b in self.bids[-3:]] - if all(b == 'PASS' for b in last_three): - self._finalize_contract() - if self.contract: - self.phase = 'playing' - self.current_player = (self.contract['declarer'] + 1) % 4 - else: - self.phase = 'finished' - self.raw_score = {'NS': 0, 'EW': 0} - self.vp_score = {'NS': 0.5, 'EW': 0.5} - return True - self.current_player = (self.current_player + 1) % 4 - return True - - def _finalize_contract(self): - contract_bids = [b for b in self.bids if b['bid'] not in ['PASS', 'DOUBLE', 'REDOUBLE']] - if not contract_bids: - self.contract = None - return - last_bid = contract_bids[-1] - bid_str = last_bid['bid'] - level = int(bid_str[0]) - suit = bid_str[1:] - bid_team = 'NS' if last_bid['position'] % 2 == 0 else 'EW' - declarer = None - for bid_record in self.bids: - if bid_record['bid'] not in ['PASS', 'DOUBLE', 'REDOUBLE']: - bid_suit = bid_record['bid'][1:] - team = 'NS' if bid_record['position'] % 2 == 0 else 'EW' - if bid_suit == suit and team == bid_team: - declarer = bid_record['position'] - break - self.contract = {'level': level, 'suit': suit, 'declarer': declarer, 'doubled': False, 'redoubled': False} - - def get_legal_cards(self, position: int) -> list[str]: - if self.phase != 'playing' or position != self.current_player: - return [] - hand = self.hands.get(position, []) - if not self.current_trick: - return hand[:] - lead_suit = get_suit(self.current_trick[0]['card']) - cards_in_suit = [c for c in hand if get_suit(c) == lead_suit] - return cards_in_suit if cards_in_suit else hand[:] - - def play_card(self, position: int, card: str) -> bool: - if self.phase != 'playing' or position != self.current_player: - return False - if card not in self.get_legal_cards(position): - return False - self.hands[position].remove(card) - self.current_trick.append({'position': position, 'card': card}) - if len(self.current_trick) == 4: - self._complete_trick() - if all(len(hand) == 0 for hand in self.hands.values()): - self._finalize_score() - self.phase = 'finished' - else: - if len(self.current_trick) > 0: - self.current_player = (self.current_player + 1) % 4 - return True - - def _complete_trick(self): - if len(self.current_trick) != 4: - return - trump_suit = self.contract['suit'] if self.contract['suit'] != 'NT' else None - lead_suit = get_suit(self.current_trick[0]['card']) - winner_idx = 0 - winner_card = self.current_trick[0]['card'] - for i in range(1, 4): - card = self.current_trick[i]['card'] - if compare_cards(card, winner_card, trump_suit, lead_suit) > 0: - winner_idx = i - winner_card = card - winner_position = self.current_trick[winner_idx]['position'] - winner_team = 'NS' if winner_position % 2 == 0 else 'EW' - self.tricks_won[winner_team] += 1 - self.played_tricks.append(self.current_trick[:]) - self.current_trick = [] - self.current_player = winner_position - - def _finalize_score(self): - if not self.contract: - self.raw_score = {'NS': 0, 'EW': 0} - self.vp_score = {'NS': 0.5, 'EW': 0.5} - return - declarer_team = get_declarer_team(self.contract['declarer']) - tricks_made = self.tricks_won[declarer_team] - ns_raw, ew_raw = calculate_contract_score( - level=self.contract['level'], - suit=self.contract['suit'], - declarer_team=declarer_team, - tricks_made=tricks_made, - doubled=self.contract['doubled'], - redoubled=self.contract['redoubled'], - vulnerable=self.vulnerability - ) - self.raw_score = {'NS': ns_raw, 'EW': ew_raw} - self.vp_score = normalize_to_vp(ns_raw, ew_raw) - - def get_state(self, position: int = None) -> dict: - state = { - 'game_id': self.game_id, - 'phase': self.phase, - 'dealer': self.dealer, - 'vulnerability': self.vulnerability, - 'players': dict(self.players), - 'current_player': self.current_player - } - state['bids'] = self.bids[:] - state['contract'] = self.contract - if self.phase in ['playing', 'finished']: - state['current_trick'] = self.current_trick[:] - state['tricks_won'] = dict(self.tricks_won) - if position is not None: - state['hand'] = self.hands.get(position, []) - else: - state['all_hands'] = self.hands - if self.phase == 'finished': - state['raw_score'] = self.raw_score - state['vp_score'] = self.vp_score - return state - - def get_result(self) -> dict: - if self.phase != 'finished': - return {} - return { - 'game_id': self.game_id, - 'contract': self.contract, - 'tricks_won': self.tricks_won, - 'raw_score': self.raw_score, - 'normalized_score': self.vp_score, - 'bids': self.bids, - 'played_tricks': self.played_tricks - } diff --git a/codeclash/arenas/bridge/game_server/scoring.py b/codeclash/arenas/bridge/game_server/scoring.py deleted file mode 100644 index 0669fa1c..00000000 --- a/codeclash/arenas/bridge/game_server/scoring.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Bridge scoring rules.""" - - -def calculate_contract_score( - level: int, - suit: str, - declarer_team: str, - tricks_made: int, - doubled: bool, - redoubled: bool, - vulnerable: dict[str, bool] -) -> tuple[int, int]: - """Calculate the score for a Bridge contract.""" - tricks_needed = 6 + level - is_vulnerable = vulnerable.get(declarer_team, False) - - if tricks_made >= tricks_needed: - score = _calculate_made_contract( - level, suit, tricks_made, tricks_needed, - doubled, redoubled, is_vulnerable - ) - else: - undertricks = tricks_needed - tricks_made - score = -_calculate_undertrick_penalty( - undertricks, doubled, redoubled, is_vulnerable - ) - - if declarer_team == 'NS': - return (score, -score) - else: - return (-score, score) - - -def _calculate_made_contract( - level: int, suit: str, tricks_made: int, tricks_needed: int, - doubled: bool, redoubled: bool, vulnerable: bool -) -> int: - """Calculate points for making a contract.""" - if suit in ['C', 'D']: - base_per_trick = 20 - elif suit in ['H', 'S']: - base_per_trick = 30 - else: # NT - base_per_trick = 30 - - trick_points = base_per_trick * level - if suit == 'NT': - trick_points += 10 - - if redoubled: - trick_points *= 4 - elif doubled: - trick_points *= 2 - - overtricks = tricks_made - tricks_needed - overtrick_points = 0 - - if overtricks > 0: - if doubled or redoubled: - per_overtrick = 100 if vulnerable else 50 - if redoubled: - per_overtrick *= 2 - overtrick_points = per_overtrick * overtricks - else: - overtrick_points = base_per_trick * overtricks - - bonus = 0 - if trick_points >= 100: - bonus += 500 if vulnerable else 300 - else: - bonus += 50 - - if level == 6 and tricks_made >= 12: - bonus += 750 if vulnerable else 500 - - if level == 7 and tricks_made == 13: - bonus += 1500 if vulnerable else 1000 - - if redoubled: - bonus += 100 - elif doubled: - bonus += 50 - - return trick_points + overtrick_points + bonus - - -def _calculate_undertrick_penalty( - undertricks: int, doubled: bool, redoubled: bool, vulnerable: bool -) -> int: - """Calculate penalty for failing to make contract.""" - if not doubled and not redoubled: - per_undertrick = 100 if vulnerable else 50 - return per_undertrick * undertricks - - penalties = [] - for i in range(undertricks): - if i == 0: - penalty = 200 if vulnerable else 100 - elif i < 3: - penalty = 300 if vulnerable else 200 - else: - penalty = 300 - - if redoubled: - penalty *= 2 - - penalties.append(penalty) - - return sum(penalties) - - -def normalize_to_vp(ns_raw: int, ew_raw: int) -> dict[str, float]: - """Normalize raw scores to Victory Points (VP) on 0-1 scale.""" - diff = ns_raw - ew_raw - imps = diff / 30.0 - vp_diff = max(-1.0, min(1.0, imps / 10.0)) - - ns_vp = 0.5 + vp_diff / 2 - ew_vp = 0.5 - vp_diff / 2 - - return { - 'NS': round(ns_vp, 3), - 'EW': round(ew_vp, 3) - } - - -def get_declarer_team(declarer_position: int) -> str: - """Get team name from declarer position.""" - return 'NS' if declarer_position % 2 == 0 else 'EW' From fe5f9bd7387810e9b448b41422030ce08db90a56 Mon Sep 17 00:00:00 2001 From: muhtasham Date: Thu, 11 Dec 2025 23:25:08 +0100 Subject: [PATCH 4/4] Fix Bridge arena to run simulations inside Docker - Refactor bridge.py to use run_game.py runner script (like RobotRumble) - Add example config Bridge__claude-3-5-haiku__r2__s10.yaml - Games now execute properly with correct scoring --- codeclash/arenas/bridge/bridge.py | 145 +++++------------- .../Bridge__claude-3-5-haiku__r2__s10.yaml | 74 +++++++++ 2 files changed, 112 insertions(+), 107 deletions(-) create mode 100644 configs/examples/Bridge__claude-3-5-haiku__r2__s10.yaml diff --git a/codeclash/arenas/bridge/bridge.py b/codeclash/arenas/bridge/bridge.py index bea00052..53b6e550 100644 --- a/codeclash/arenas/bridge/bridge.py +++ b/codeclash/arenas/bridge/bridge.py @@ -1,9 +1,10 @@ """Bridge Arena for CodeClash.""" import json -import sys +import shlex +import subprocess +from collections import Counter from concurrent.futures import ThreadPoolExecutor, as_completed -from pathlib import Path from tqdm.auto import tqdm @@ -42,6 +43,7 @@ def __init__(self, config, **kwargs): if num_players != 4: raise ValueError(f"Bridge requires exactly 4 players, got {num_players}") super().__init__(config, **kwargs) + self.run_cmd = "python3 /workspace/run_game.py" def validate_code(self, agent: Player) -> tuple[bool, str | None]: """Validate agent code has required functions.""" @@ -66,120 +68,44 @@ def validate_code(self, agent: Player) -> tuple[bool, str | None]: return True, None - def _run_single_simulation(self, agents: list[Player], idx: int): + def _run_single_simulation(self, agents: list[Player], idx: int, cmd: str): """Run a single Bridge game simulation.""" - game_server_path = str(Path(self.environment.config.cwd) / "game_server") - try: - # Import BridgeGame from game_server (cloned from CodeClash-ai/Bridge repo) - sys.path.insert(0, game_server_path) - from game import BridgeGame - - # Create game with random seed for reproducibility - game = BridgeGame(seed=idx, dealer=idx % 4) + full_cmd = f"{cmd} -o {self.log_env / f'sim_{idx}.json'}" - # Add players at positions 0-3 (North, East, South, West) - for position, agent in enumerate(agents): - game.add_player(position, agent.name) - - # Start game (deals cards) - if not game.start_game(): - self.logger.error(f"Simulation {idx}: Failed to start game") - return + try: + response = self.environment.execute(full_cmd, timeout=60) + except subprocess.TimeoutExpired: + self.logger.warning(f"Bridge simulation {idx} timed out") + return "" - # Import agent modules dynamically - agent_modules = [] - for agent in agents: - agent_path = Path(agent.environment.config.cwd) / self.submission - spec = __import__( - 'importlib.util' - ).util.spec_from_file_location(f"agent_{agent.name}", agent_path) - module = __import__('importlib.util').util.module_from_spec(spec) - spec.loader.exec_module(module) - agent_modules.append(module) - - # Bidding phase - while game.phase == 'bidding': - current_pos = game.current_player - state = game.get_state(current_pos) - - # Prepare game_state for agent - agent_state = { - 'position': current_pos, - 'hand': state.get('hand', game.hands.get(current_pos, [])), - 'bids': state['bids'], - 'legal_bids': game.get_legal_bids(current_pos), - 'dealer': state['dealer'], - 'vulnerability': state['vulnerability'], - } - - # Get bid from agent - try: - bid = agent_modules[current_pos].get_bid(agent_state) - except Exception as e: - self.logger.error(f"Simulation {idx}: Agent {agents[current_pos].name} error in get_bid: {e}") - bid = "PASS" - - # Make bid - if not game.make_bid(current_pos, bid): - self.logger.warning( - f"Simulation {idx}: Invalid bid '{bid}' from {agents[current_pos].name}, defaulting to PASS" - ) - game.make_bid(current_pos, "PASS") - - # Playing phase - while game.phase == 'playing': - current_pos = game.current_player - state = game.get_state(current_pos) - - # Prepare game_state for agent - agent_state = { - 'position': current_pos, - 'hand': state.get('hand', game.hands.get(current_pos, [])), - 'current_trick': state['current_trick'], - 'legal_cards': game.get_legal_cards(current_pos), - 'contract': state['contract'], - 'tricks_won': state['tricks_won'], - } - - # Get card from agent - try: - card = agent_modules[current_pos].play_card(agent_state) - except Exception as e: - self.logger.error(f"Simulation {idx}: Agent {agents[current_pos].name} error in play_card: {e}") - legal = game.get_legal_cards(current_pos) - card = legal[0] if legal else "AS" - - # Play card - if not game.play_card(current_pos, card): - self.logger.warning( - f"Simulation {idx}: Invalid card '{card}' from {agents[current_pos].name}, using first legal card" - ) - legal = game.get_legal_cards(current_pos) - if legal: - game.play_card(current_pos, legal[0]) - - # Save result to JSON log - result = game.get_result() - log_file = self.log_env / f"sim_{idx}.json" - with open(log_file, 'w') as f: - json.dump(result, f, indent=2) - - except Exception as e: - self.logger.error(f"Simulation {idx} failed with error: {e}") - finally: - # Clean up sys.path - if game_server_path in sys.path: - sys.path.remove(game_server_path) + if response["returncode"] != 0: + self.logger.warning( + f"Bridge simulation {idx} failed with exit code {response['returncode']}:\n{response['output']}" + ) + return response["output"] def execute_round(self, agents: list[Player]): """Execute a round of Bridge games.""" - sims = self.game_config['sims_per_round'] + sims = self.game_config.get('sims_per_round', 10) self.logger.info(f"Running {sims} Bridge simulations with 4 players") + # Build agent paths for the command + agent_paths = [] + for agent in agents: + agent_paths.append(f"/{agent.name}/{self.submission}") + + # Build base command + cmd = f"{self.run_cmd} {shlex.join(agent_paths)}" + # Run simulations in parallel - with ThreadPoolExecutor(max_workers=20) as executor: + with ThreadPoolExecutor(max_workers=8) as executor: futures = [ - executor.submit(self._run_single_simulation, agents, idx) + executor.submit( + self._run_single_simulation, + agents, + idx, + f"{cmd} --seed {idx} --dealer {idx % 4}" + ) for idx in range(sims) ] for future in tqdm(as_completed(futures), total=len(futures), desc="Bridge simulations"): @@ -192,7 +118,7 @@ def get_results(self, agents: list[Player], round_num: int, stats: RoundStats): games_played = 0 # Parse all simulation logs - for idx in range(self.game_config['sims_per_round']): + for idx in range(self.game_config.get('sims_per_round', 10)): log_file = self.log_round(round_num) / f"sim_{idx}.json" if not log_file.exists(): @@ -203,6 +129,11 @@ def get_results(self, agents: list[Player], round_num: int, stats: RoundStats): with open(log_file) as f: result = json.load(f) + # Check for error + if 'error' in result: + self.logger.warning(f"Simulation {idx} had error: {result['error']}") + continue + # Extract VP scores for each team vp_scores = result.get('normalized_score', {}) if vp_scores: diff --git a/configs/examples/Bridge__claude-3-5-haiku__r2__s10.yaml b/configs/examples/Bridge__claude-3-5-haiku__r2__s10.yaml new file mode 100644 index 00000000..0853e30b --- /dev/null +++ b/configs/examples/Bridge__claude-3-5-haiku__r2__s10.yaml @@ -0,0 +1,74 @@ +tournament: + rounds: 2 +game: + name: Bridge + sims_per_round: 10 +players: +- agent: mini + name: north + config: + agent: !include mini/default.yaml + model: + model_name: 'anthropic/claude-3-5-haiku-20241022' + model_kwargs: + temperature: 0.2 + max_tokens: 4096 +- agent: mini + name: east + config: + agent: !include mini/default.yaml + model: + model_name: 'anthropic/claude-3-5-haiku-20241022' + model_kwargs: + temperature: 0.2 + max_tokens: 4096 +- agent: mini + name: south + config: + agent: !include mini/default.yaml + model: + model_name: 'anthropic/claude-3-5-haiku-20241022' + model_kwargs: + temperature: 0.2 + max_tokens: 4096 +- agent: mini + name: west + config: + agent: !include mini/default.yaml + model: + model_name: 'anthropic/claude-3-5-haiku-20241022' + model_kwargs: + temperature: 0.2 + max_tokens: 4096 +prompts: + game_description: |- + You are a software developer ({{player_id}}) competing in a coding game called Bridge. + Bridge is a 4-player trick-taking card game played in partnerships: North/South vs East/West. + + Your position: {{player_id}} (North=0, East=1, South=2, West=3) + Teams: North/South (positions 0/2) vs East/West (positions 1/3) + + The game is played in {{total_rounds}} rounds. For every round, you (and your competitors) edit program code that controls your bot. This is round {{round}}. + After everyone finishes editing their codebases, the game is run automatically. + + Your task: improve the bot in `bridge_agent.py`, located in {{working_dir}}. + {{working_dir}} is your codebase, which contains both your bot and supporting assets. + All of your commands will be executed in the {{working_dir}} directory. + + Your bot must implement two functions: + - get_bid(game_state) -> str: Make bidding decisions during the auction + - play_card(game_state) -> str: Play a card during the play phase + + game_state contains: + - position: Your seat (0-3) + - hand: Your cards (e.g., ["AS", "KH", "7D", "TC"]) + - legal_bids/legal_cards: Valid moves you can make + - bids: Previous bids in the auction + - current_trick: Cards played in current trick + - contract: The final contract (after bidding) + - tricks_won: Tricks won by each team + + Card notation: where rank is A,K,Q,J,T,9,8,7,6,5,4,3,2 and suit is S,H,D,C + Bid notation: "PASS" or level(1-7) + strain(C,D,H,S,NT) like "1H", "3NT", "7S" + + Check examples/random_agent.py in the workspace for a starting template.