diff --git a/agents/chatgpt.py b/agents/chatgpt.py new file mode 100644 index 0000000..f1bfe2a --- /dev/null +++ b/agents/chatgpt.py @@ -0,0 +1,1110 @@ +# ----------------------------------------------------------------------------- +# This file was generated by an AI language model. +# Model: ChatGPT (OpenAI) GPT-5.5 +# Generated: 2026-07-21T00:00:00Z +# +# As with any AI-generated code, it should be reviewed and tested before use. +# ----------------------------------------------------------------------------- + +from __future__ import annotations + +from .agent import Agent, Payload + + +class ChatGPTAgent(Agent): + """ + A reasonably strong general-purpose agent. + + Tic-tac-toe: + - Perfect play using minimax. + Royal Game of Ur: + - Rule-based heuristic prioritizing: + * finishing tokens + * capturing + * star squares + * advancing furthest pieces + """ + + def __init__(self) -> None: + super().__init__( + name="ChatGPT", + author="OpenAI", + version="1.0.0", + profile={ + "model": "GPT-5.5", + "games": [ + "tic-tac-toe", + "royal-game-of-ur", + ], + }, + ) + + self.add_tic_tac_toe( + on_move=self.play_tic_tac_toe, + profile={"strategy": "minimax"}, + ) + + self.add_royal_game_of_ur( + on_move=self.play_royal_game_of_ur, + profile={"strategy": "heuristic"}, + ) + + # ------------------------------------------------------------------ + # Tic-Tac-Toe + # ------------------------------------------------------------------ + + WIN_LINES = ( + (1, 2, 3), + (4, 5, 6), + (7, 8, 9), + (1, 4, 7), + (2, 5, 8), + (3, 6, 9), + (1, 5, 9), + (3, 5, 7), + ) + + @classmethod + def _winner(cls, board): + for a, b, c in cls.WIN_LINES: + if board[a] != "" and board[a] == board[b] == board[c]: + return board[a] + return None + + @classmethod + def _minimax(cls, board, player, me): + winner = cls._winner(board) + opponent = "O" if me == "X" else "X" + + if winner == me: + return 1, None + if winner == opponent: + return -1, None + + empty = [i for i in range(1, 10) if board[i] == ""] + if not empty: + return 0, None + + maximizing = player == me + + best_score = -2 if maximizing else 2 + best_move = None + + next_player = "O" if player == "X" else "X" + + for move in empty: + board[move] = player + score, _ = cls._minimax(board, next_player, me) + board[move] = "" + + if maximizing: + if score > best_score: + best_score = score + best_move = move + else: + if score < best_score: + best_score = score + best_move = move + + return best_score, best_move + + @classmethod + def play_tic_tac_toe(cls, payload: Payload) -> Payload: + board = { + i: payload.get(str(i), "") + for i in range(1, 10) + } + + me = payload["your_token"] + + _, move = cls._minimax( + board, + me, + me, + ) + + if move is None: + for i in range(1, 10): + if board[i] == "": + move = i + break + + return {"move": move} + + # ------------------------------------------------------------------ + # Royal Game of Ur + # ------------------------------------------------------------------ + + @staticmethod + def play_royal_game_of_ur(payload: Payload) -> Payload: + valid_moves = payload.get("valid_moves", []) + + if not valid_moves: + return {"move": 0} + + safe = set(payload["safe_fields"]) + stars = set(payload["star_fields"]) + + best_move = valid_moves[0] + best_score = -10**9 + + for start in valid_moves: + dest = start + payload["roll"] + + score = 0 + + # Finishing a token is excellent. + if dest == 15: + score += 10000 + + # Capturing an enemy. + if ( + dest not in safe + and payload.get(str(dest), "") == "ENEMY" + ): + score += 5000 + + # Extra turn. + if dest in stars: + score += 2000 + + # Prefer advancing already-progressed tokens. + score += dest * 25 + + # Prefer moving pieces already on the board. + if start != 0: + score += 100 + + # Small preference for leaving the start square. + if start == 0: + score += 10 + + if score > best_score: + best_score = score + best_move = start + + return {"move": best_move} + +""" +This code was generated using ChatGPT. The prompt can be reviewed below. + +=============================================================================== + +Please write an agent that can play tic-tac-toe and the royal game of ur. Your output will be written to `agents/chatgpt.py`. + + +At the top of the file, please write a small comment that explains that your code was written by you, an AI agent. Please specify your name, version and a timestamp of when the code was generated top give some insights in the quality of your code generation. + + +For context, here's a few files that are available in the repository: + + +At `README.md`: + + +```md +# Bot-Man-Toe + + +Write a script that plays tic-tac-toe, and see how well it performs against +other programs! + + +- [🚀 Write your own agent](agents/README.md) +- [🖊 Compare how well your agent performs](elo_tracker/README.md) +- [🌐 Publish your agent to the internet](pyserver/README.md) + + +## Get started + + +1. Clone this repository. +2. Run `python client.py` in the terminal and let two random agents play + against each other. +3. Copy the example agent and [create your own](agents/README.md). +4. Play against the AgentOfChaos while you improve your strategy. +5. Publish your agent. _(optional)_ +6. Compare it against others with the Elo tracker. _(optional)_ + + + +## Links + + +- [📜 API specification](pyserver/spec.md) +- [🏆 Online ELO tracker](https://elo.noordstar.me/) +``` + + +At `agents/README.md`: + + +```md +# 🚀 Write your own agent! + + +An **agent** is a Python class that knows how to play one or more games. + + +Don't worry about writing a perfect strategy. Start with something that works, +print the incoming game state, and improve it one step at a time. + + +## Quick start + + +1. Copy [example.py](example.py). +2. Rename the file and the `ExampleAgent` class. +3. Update the agent's name, author and version. +4. Implement `play_tic_tac_toe()`. +5. Import your agent in `client.py` and let it play a game. + + +That's enough to get started. + + +## Understanding the game + + +Whenever your agent has to make a move, it receives the current game state as +a Python dictionary. + + +Start by printing it: + + +```py +print(payload) +``` + + +Run a few games and watch how the payload changes after every move. Once you +understand what you're receiving, you can start writing your own strategy! + + +Your function should return: + + +```py +{"move": 7} +``` + + +where the number is the square you want to play. + + +## Testing your agent + + +Open [client.py](../client.py) and replace one of the players with your own agent. +For example: + + +```py +from agents.my_agent import MyAgent +from agents.chaos import AgentOfChaos + + +players = [ + MyAgent(), + AgentOfChaos(), +] +``` + + +The repository includes `AgentOfChaos`, a very simple opponent that plays +random moves. It's useful for testing your own agent while you're developing it. + + +> ⚠️ Don't try to build the perfect player immediately! Agents are easy to +> improve while you test against other agents. Plus, imperfect agents are +> typically the most interesting. + + +Run the client, inspect the output, tweak your algorithm, and repeat. You don't +need to understand the rest of the project before you can start experimenting. + + +## What's next? + + +Once you're happy with your agent, you can: + + +- [🖊 Compare your agent against other agents with the ELO tracker](../elo_tracker/README.md) +- [🌐 Publish your agent so other people can play against it](../pyserver/README.md) +``` + + +At `agents/example.py`: + + +```py +\""" + This module contains an example agent that you can use to create your own! + + + Please copy this file, rename it, and then build it the way you see fit. +\""" + + +from __future__ import annotations + + +import random + + +from .agent import Agent, Payload + + +class ExampleAgent(Agent): + \""" + Describe here what your agent does and how it behaves! This will help + others understand how your agent works. + \""" + + + def __init__(self) -> None: + \""" + Create a custom instance of your agent. This function allows you + to define which games your agent can play. + + + You may add parameters to the function if your agent requires more + information to be able to operate. + \""" + + + super().__init__( + # Give your bot a name to display in leaderboards + name="MY super smart agent", + + + # Your name, to give you credit + author="Unknown programmer", + + + # Update the version to indicate the agent behaves differently. + # This will later allow you to compare different versions of your + # agent against one another. + version="1.0.0", + + + # Add extra custom information about the agent to this dictionary. + profile={}, + ) + + + # Indicate that you're willing to play tic-tac-toe + # Remove this if you don't want your bot to participate there. + self.add_tic_tac_toe(on_move=self.play_tic_tac_toe, profile={}) + + + @staticmethod + def play_tic_tac_toe(payload : Payload) -> Payload: + \""" + Play a game of tic-tac-toe. + + + You receive a payload that looks like this: + + + { + "1": "X", "2": "", "3": "O", + "4": "X", "5": "O", "6": "", + "7": "", "8": "", "9": "", + "your_token": "X" + } + + + And you're expected to return a response of which field you'd like to + place your piece in. For example, if you wish to place your token in + field 7, your response should look like this: + + + { "move": 7 } + + + The board is arranged as follows: + + + 1 | 2 | 3 + ---+---+--- + 4 | 5 | 6 + ---+---+--- + 7 | 8 | 9 + + :param payload: The incoming JSON that contains the game state. + :type payload: dict[str, Any] + :return: The move you wish to take. + :rtype: dict[str, Any] + \""" + + + # Try printing the payload to see what it looks like! + print(payload) + + + options = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, ] + + + # 1. Try filtering out the impossible moves! + # If an X or O was already placed at a field, remove it from the options + + + # + + + # 2. Try finding two in a row! If possible, you can try to place the third + # item on the board and get 3 in a row. + + + # + + + # 3. Perhaps you can block the opponent from getting 3 in a row? + + + # + + + # Now, pick any of the remaining options. + # This is just a simple implementation. Naturally, you're welcome to try + # your own algorithm. + return { "move": random.choice(options) } +``` + + +At `pyclient/games/tic_tac_toe.py`: + + +```py +\""" + This module hosts the game of tic-tac-toe, the traditional game where + you're trying to place 3 items in a row in a 3x3-grid. +\""" + + +from __future__ import annotations + + +from .game import FinishState, Game +from dataclasses import dataclass +from enum import Enum, auto +from typing import Any, Optional + + +class Field(Enum): + X = auto() + O = auto() + empty = auto() + + + def __str__(self): + \""" + Convert the field to a string. + \""" + match self: + case Field.X: + return "X" + case Field.O: + return "O" + case Field.empty: + return "" + + +@dataclass(frozen=True) +class TicTacToe(Game): + field_1 : Field + field_2 : Field + field_3 : Field + field_4 : Field + field_5 : Field + field_6 : Field + field_7 : Field + field_8 : Field + field_9 : Field + + + def __write(self, index : int, value : Field) -> "TicTacToe": + \""" + Create a new copy where one field has been written with any value. + + + :param index: The field number to replace. (1-9) + :type index: int + :param value: The field value to write. + :type value: Field + :return: A new copy where the value was written. + :rtype: TicTacToe + :raises ValueError: The field index has already been written to. + :raises KeyError: The index is an invalid number. + \""" + current_value = self.to_dict().get(str(index), None) + + + if current_value is None: + raise KeyError( + f"Index to write to should be 1-9, not {index}" + ) + elif current_value != str(Field.empty): + raise ValueError( + f"Field should be empty, not {current_value}" + ) + + match index: + case 1: + return TicTacToe( + field_1=value, + field_2=self.field_2, + field_3=self.field_3, + field_4=self.field_4, + field_5=self.field_5, + field_6=self.field_6, + field_7=self.field_7, + field_8=self.field_8, + field_9=self.field_9, + ) + case 2: + return TicTacToe( + field_1=self.field_1, + field_2=value, + field_3=self.field_3, + field_4=self.field_4, + field_5=self.field_5, + field_6=self.field_6, + field_7=self.field_7, + field_8=self.field_8, + field_9=self.field_9, + ) + case 3: + return TicTacToe( + field_1=self.field_1, + field_2=self.field_2, + field_3=value, + field_4=self.field_4, + field_5=self.field_5, + field_6=self.field_6, + field_7=self.field_7, + field_8=self.field_8, + field_9=self.field_9, + ) + case 4: + return TicTacToe( + field_1=self.field_1, + field_2=self.field_2, + field_3=self.field_3, + field_4=value, + field_5=self.field_5, + field_6=self.field_6, + field_7=self.field_7, + field_8=self.field_8, + field_9=self.field_9, + ) + case 5: + return TicTacToe( + field_1=self.field_1, + field_2=self.field_2, + field_3=self.field_3, + field_4=self.field_4, + field_5=value, + field_6=self.field_6, + field_7=self.field_7, + field_8=self.field_8, + field_9=self.field_9, + ) + case 6: + return TicTacToe( + field_1=self.field_1, + field_2=self.field_2, + field_3=self.field_3, + field_4=self.field_4, + field_5=self.field_5, + field_6=value, + field_7=self.field_7, + field_8=self.field_8, + field_9=self.field_9, + ) + case 7: + return TicTacToe( + field_1=self.field_1, + field_2=self.field_2, + field_3=self.field_3, + field_4=self.field_4, + field_5=self.field_5, + field_6=self.field_6, + field_7=value, + field_8=self.field_8, + field_9=self.field_9, + ) + case 8: + return TicTacToe( + field_1=self.field_1, + field_2=self.field_2, + field_3=self.field_3, + field_4=self.field_4, + field_5=self.field_5, + field_6=self.field_6, + field_7=self.field_7, + field_8=value, + field_9=self.field_9, + ) + case 9: + return TicTacToe( + field_1=self.field_1, + field_2=self.field_2, + field_3=self.field_3, + field_4=self.field_4, + field_5=self.field_5, + field_6=self.field_6, + field_7=self.field_7, + field_8=self.field_8, + field_9=value, + ) + case _: + raise KeyError( + f"Index to write to should be 1-9, not {index}" + ) + + def action_name(self) -> str: + return "" + + def as_seen_by(self, player : int) -> dict[str, Any]: + \""" + Return the view of the game from the perspective of a given player. + + + :param player: The perspective on the board. + :type player: int + \""" + out = self.to_dict() + out["your_token"] = str(Field.X) if player == 1 else str(Field.O) + return out + + + def count_o(self) -> int: + \""" + Count the number of O's on the board. + \""" + return list(self.to_dict().values()).count(str(Field.O)) + + + def count_x(self) -> int: + \""" + Count the number of X's on the board. + \""" + return list(self.to_dict().values()).count(str(Field.X)) + + @classmethod + def empty(cls): + return cls( + field_1=Field.empty, field_2=Field.empty, field_3=Field.empty, + field_4=Field.empty, field_5=Field.empty, field_6=Field.empty, + field_7=Field.empty, field_8=Field.empty, field_9=Field.empty, + ) + + + def game_name(self) -> str: + return "tic-tac-toe" + + def move_default(self) -> "TicTacToe": + \""" + Have a player take a "default" move. They'll take this move + whenever their response is invalid, or when they take too long + to decide, or when they're no longer accessible. + + + :return: New state of the board. + :rtype: TicTacToe + :raises ValueError: When no more moves exist. By this point, someone should've already won. + \""" + symbol = Field.X if self.player_to_move() == 1 else Field.O + + + for i in range(9): + try: + out = self.__write(i+1, symbol) + except ValueError: + # Field already occupied! Move to the next option. + pass + else: + return out + else: + # All moves seem invalid! + raise ValueError( + "No legal moves exist anymore on this tic-tac-toe board." + ) + + + def move(self, payload : Optional[dict[str, Any]] = None) -> "TicTacToe": + \""" + Have a player make a move. Based on this information, update the + game. + + + :param payload: Dictionary containing the player's response. + :type payload: Optional[Dict[str, Any]] + :return: New state of the board. + :rtype: TicTacToe + :raises ValueError: When no more moves exist. By this point, someone should've already won. + \""" + symbol = Field.X if self.player_to_move() == 1 else Field.O + + + # Extract field to place symbol at + move = 1 + if isinstance(payload, dict): + move = payload.get("move", None) + + + if not isinstance(move, int): + move = None + else: + move = move if 1 <= move <= 9 else None + + + if move is not None: + try: + out = self.__write(move, symbol) + except ValueError: + # Invalid move! We'll try any other move. + pass + else: + return out + + # The player chose an invalid move. + return self.move_default() + + + + def player_to_move(self) -> int: + \""" + Return which player needs to move. + \""" + return 1 if self.count_x() <= self.count_o() else 2 + + def to_dict(self) -> dict[str, Any]: + return { + "1": str(self.field_1), + "2": str(self.field_2), + "3": str(self.field_3), + "4": str(self.field_4), + "5": str(self.field_5), + "6": str(self.field_6), + "7": str(self.field_7), + "8": str(self.field_8), + "9": str(self.field_9), + } + + def winner(self) -> dict[int, FinishState] | None: + \""" + Returns whether the board indicates that there's a winner. + + + :return: The winning player, zero in case of a tie, or None if there's no winner yet. + :rtype: dict[int, FinishState] | None + \""" + win_lines = [ + [ 1, 2, 3, ], + [ 4, 5, 6, ], + [ 7, 8, 9, ], + [ 1, 4, 7, ], + [ 2, 5, 8, ], + [ 3, 6, 9, ], + [ 1, 5, 9, ], + [ 3, 5, 7, ], + ] + + + d = self.to_dict() + out = { 1 : FinishState.loss, 2 : FinishState.loss, } + + + for player, symbol in [ ( 1, str(Field.X) ), ( 2, str(Field.O) ) ]: + for win_line in win_lines: + if all(d[str(w)] == symbol for w in win_line): + out[player] = FinishState.win + return out + else: + # Check for draw + if all(item != "" for item in d.values()): + return { 1 : FinishState.draw, 2 : FinishState.draw, } + + return None +``` + + +At `pyclient/games/ur.py`: + + +```py +\""" + The Royal game of Ur is supposedly one of the world's oldest known board + games. Players race 7 horses to the finish line while trying to sabotage + each other. +\""" + + +from __future__ import annotations +from typing import Any, Generator + + +import random + + +from .game import FinishState, Game +from dataclasses import dataclass + + +# On these fields, you can safely place your token without getting it removed +# if the opponent plays a token on their respective spot. +SAFE_FIELDS = set([ 0, 1, 2, 3, 4, 13, 14, 15 ]) + + +# Whenever you land a token on this piece, you may play another turn. +STAR_FIELDS = set([ 4, 8, 14 ]) + + +# You may place multiple pieces on these fields. +STACK_FIELDS = set([ 0, 15 ]) + + +# Length of the board +BOARD_LENGTH = 1 + 4 + (4 + 2 + 2) + 2 + 1 + + +@dataclass(frozen=True) +class RoyalGameOfUr(Game): + \""" + The Royal Game of Ur is a game where players roll dice to get to the + finish line. + \""" + board : list[tuple[int, int]] + mover : int + roll : int + + def __updated_board(self, start : int) -> list[tuple[int, int]]: + new_board = [] + + + assert self.roll > 0 + assert 0 <= start < BOARD_LENGTH + assert start + self.roll < BOARD_LENGTH + + + elim = False + + + for i, (a, b) in enumerate(self.board): + if i == start: + if self.player_to_move() == 1: + new_board.append(( a - 1, b )) + else: + new_board.append(( a, b - 1 )) + elif i == start + self.roll: + if self.player_to_move() == 1: + if b > 0 and i not in SAFE_FIELDS: + elim = True + b = 0 + new_board.append(( a + 1, b )) + else: + if a > 0 and i not in SAFE_FIELDS: + elim = True + a = 0 + new_board.append(( a, b + 1 )) + else: + new_board.append(( a, b )) + + + if elim: + if self.player_to_move() == 1: + new_board[0] = ( new_board[0][0], new_board[0][1] + 1) + else: + new_board[0] = ( new_board[0][0] + 1, new_board[0][1] ) + + + return new_board + + + def __valid_moves(self) -> Generator[int, None, None]: + for i in range(BOARD_LENGTH - self.roll): + dest = i + self.roll + + # Skip star fields if they're already occupied + if dest in STAR_FIELDS and dest not in SAFE_FIELDS and self.board[dest] != ( 0, 0 ): + continue + + + if self.board[i][self.mover] > 0: + if dest in STACK_FIELDS or self.board[dest][self.mover] == 0: + yield i + + def action_name(self) -> str: + return "" # Players can only move in this game + + def as_seen_by(self, player: int) -> dict[str, Any]: + my_index = player - 1 + ot_index = 1 - my_index + + d : dict[str, Any] = dict( + enemies_at_start=self.board[0][ot_index], + enemies_at_finish=self.board[BOARD_LENGTH-1][ot_index], + roll=self.roll, + safe_fields=list(SAFE_FIELDS), + stack_fields=list(STACK_FIELDS), + star_fields=list(STAR_FIELDS), + tokens_at_start=self.board[0][my_index], + tokens_at_finish=self.board[BOARD_LENGTH-1][my_index], + valid_moves=list(self.__valid_moves()), + ) + + + for i in range(BOARD_LENGTH): + d[str(i)] = ( + "YOU" if self.board[i][my_index] > 0 else ( + "ENEMY" if i not in SAFE_FIELDS and self.board[i][ot_index] > 0 else ( + "" + ))) + + for i in SAFE_FIELDS: + d[f"{i}_enemy"] = ( + "ENEMY" if self.board[i][ot_index] > 0 else "" + ) + + + return d + + @classmethod + def empty(cls) -> RoyalGameOfUr: + \""" + Create a new game. + + + :return: Start state of the board + :rtype: RoyalGameOfUr + \""" + times, roll = cls.roll_repeatedly() + + + return cls( + board=[ (7, 7) ] + ((BOARD_LENGTH - 1) * [ (0, 0 ) ]), + mover=(1 + times) % 2, + roll=roll, + ) + + def game_name(self) -> str: + return "royal-game-of-ur" + + def move_default(self) -> Game: + \""" + Fallback move option for when a user isn't available or cannot + decide. In this case, the player attempts to move whatever is the + furthest in the back. + \""" + player = self.player_to_move() + + + for i in self.__valid_moves(): + new_board = self.__updated_board(start=i) + new_mover = self.mover + if i + self.roll not in STAR_FIELDS: + new_mover += 1 + + return RoyalGameOfUr( + board=new_board, mover=new_mover % 2, roll=self.roll_once(), + ).skip_if_possible() + else: + raise ValueError( + "Player has no valid moves even though those should've been erased." + ) + + + def move(self, payload : dict[str, Any] | None = None): + if not isinstance(payload, dict): + return self.move_default() + + + key = str(payload.get("move", "")) + + + for i in self.__valid_moves(): + if str(i) == key: + new_board = self.__updated_board(start=i) + new_mover = self.mover + if i + self.roll not in STAR_FIELDS: + new_mover += 1 + + return RoyalGameOfUr( + board=new_board, mover=new_mover % 2, roll=self.roll_once(), + ).skip_if_possible() + else: + return self.move_default() + + + def player_to_move(self) -> int: + return self.mover + 1 + + + @staticmethod + def roll_once() -> int: + tot = 0 + for _ in range(4): + tot += random.randint(0, 1) + return tot + + @staticmethod + def roll_repeatedly() -> tuple[int, int]: + \""" + Roll until you don't roll a zero. + \""" + n, roll = 0, 0 + + + while roll == 0: + n += 1 + roll = RoyalGameOfUr.roll_once() + + return n, roll + + + def skip_if_possible(self) -> RoyalGameOfUr: + \""" + Skip this turn. Allow the next player to make a move. + Players are not allowed to skip if they can make a valid move. + \""" + # Exit if at least one valid move exists + if self.roll > 0: + for _ in self.__valid_moves(): + return self + + # Exit if game has already finished + if self.winner() is not None: + return self + + return RoyalGameOfUr( + board=self.board, + mover=(self.mover + 1) % 2, + roll=self.roll_once() + ).skip_if_possible() + + def to_dict(self) -> dict[str, Any]: + return self.as_seen_by(player=1) + + def winner(self) -> dict[int, FinishState] | None: + a, b = self.board[-1] + + + if a == 7: + return { + 1 : FinishState.win, + 2 : FinishState.loss, + } + elif b == 7: + return { + 1 : FinishState.loss, + 2 : FinishState.win, + } + else: + return None +``` +""" \ No newline at end of file diff --git a/server.py b/server.py index a3c7c5b..0e10a7f 100644 --- a/server.py +++ b/server.py @@ -5,6 +5,7 @@ from __future__ import annotations import agents +import agents.chatgpt from pyserver import PyServer @@ -15,7 +16,7 @@ def main() -> int: :return: Exit code :rtype: int """ - player = PyServer(agents.AgentOfChaos()) + player = PyServer(agents.chatgpt.ChatGPTAgent()) # Start listening for games player.start(