From da4a3bc4c0ea4660603ef1296f749a7c3500f428 Mon Sep 17 00:00:00 2001 From: cchung Date: Fri, 25 Aug 2023 14:34:55 +0000 Subject: [PATCH] Initial commit --- .commit_template | 9 ++++ .gitignore | 3 ++ nim/game_stub.py | 34 ++++++++++++ nim/player.py | 35 +++++++++++++ nim/view.py | 32 ++++++++++++ notes.md | 35 +++++++++++++ play_nim.py | 15 ++++++ play_ttt.py | 15 ++++++ poetry.lock | 33 ++++++++++++ pyproject.toml | 16 ++++++ strategy/lookahead_strategy.py | 96 ++++++++++++++++++++++++++++++++++ strategy/random_strategy.py | 11 ++++ ttt/game.py | 61 +++++++++++++++++++++ ttt/player.py | 33 ++++++++++++ ttt/view.py | 75 ++++++++++++++++++++++++++ 15 files changed, 503 insertions(+) create mode 100644 .commit_template create mode 100644 .gitignore create mode 100644 nim/game_stub.py create mode 100644 nim/player.py create mode 100644 nim/view.py create mode 100644 notes.md create mode 100644 play_nim.py create mode 100644 play_ttt.py create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 strategy/lookahead_strategy.py create mode 100644 strategy/random_strategy.py create mode 100644 ttt/game.py create mode 100644 ttt/player.py create mode 100644 ttt/view.py diff --git a/.commit_template b/.commit_template new file mode 100644 index 0000000..f634563 --- /dev/null +++ b/.commit_template @@ -0,0 +1,9 @@ +(Commit summary. Replace this with a one-line description of this commit.) + +What I changed +(Replace this with a description of what you changed in this commit. This should be 1-2 sentences.) + +Why I changed it +(Describe why you made these changes. Were you working toward a goal? Did you reorganize your code? This should be 1-2 sentences.) + +Estimate for remaining time to finish assignment: [REPLACE WITH TIME ESTIMATE] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..108c744 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.swp +*.swo +**/__pycache__/* diff --git a/nim/game_stub.py b/nim/game_stub.py new file mode 100644 index 0000000..b8a7d36 --- /dev/null +++ b/nim/game_stub.py @@ -0,0 +1,34 @@ +class NimGameStub: + """A stub is a minimal version of a class which stands in for the + real class, which hasn't yet been written. The stub has all the correct + methods, and their inputs and outputs are the right kind of thing, + but it doesn't really do anything. + """ + def get_initial_state(self): + return { + "board": [1, 3, 5, 7], + "first_player": True + } + + def get_next_state(self, state, action): + next_state = { + "board": state["board"].copy(), + "first_player": not state["first_player"], + } + return next_state + + def get_actions(self, state): + return [ + (0, 0), + (1, 0), (1, 1), + (2, 0), (2, 1), (2, 2), (3, 0), (3, 1), (3, 2), (3, 3), + ] + + def get_reward(self, state): + return 0 + + def is_over(self, state): + return False + + def get_objective(self, state): + return max if state["first_player"] else min diff --git a/nim/player.py b/nim/player.py new file mode 100644 index 0000000..32fd9fc --- /dev/null +++ b/nim/player.py @@ -0,0 +1,35 @@ +from nim.game_stub import NimGameStub +from strategy.lookahead_strategy import LookaheadStrategy + +class HumanNimPlayer: + def __init__(self, name): + self.name = name + self.game = NimGameStub() + + def choose_action(self, state): + actions = self.game.get_actions(state) + for i, action in enumerate(actions): + row, lines_to_remove = action + print(f"{i}. Remove {lines_to_remove} from row {row}.") + choice = self.get_int(len(actions)) + return actions[choice] + + def get_int(self, maximum): + while True: + response = input("> ") + if response.isdigit(): + value = int(response) + if value < maximum: + return value + print("Invalid input.") + +class ComputerNimPlayer: + def __init__(self, name): + self.name = name + self.strategy = LookaheadStrategy(NimGameStub(), max_depth=3, deterministic=False) + + def choose_action(self, state): + action = self.strategy.choose_action(state) + row, lines_to_remove = action + print(f"{self.name} removes {lines_to_remove} from row {row}") + return action diff --git a/nim/view.py b/nim/view.py new file mode 100644 index 0000000..16e3fdf --- /dev/null +++ b/nim/view.py @@ -0,0 +1,32 @@ +from nim.game_stub import NimGameStub + +class NimView: + def __init__(self, player0, player1): + self.players = [player0, player1] + self.game = NimGameStub() + + def greet(self): + print(f"{self.players[0].name} and {self.players[1].name}, welcome to Nim.") + + def show_board(self, state): + for lines_in_row in state["board"]: + print("| " * lines_in_row) + + def get_action(self, state): + self.show_board(state) + player = self.get_current_player(state) + return player.choose_action(state) + + def get_current_player(self, state): + if state["first_player"]: + return self.players[0] + else: + return self.players[1] + + def conclude(self, state): + self.show_board(state) + if self.game.get_reward(state) > 0: + winner = self.players[0] + else: + winner = self.players[1] + print(f"Congratulations, {winner.name}!") diff --git a/notes.md b/notes.md new file mode 100644 index 0000000..67cc9f6 --- /dev/null +++ b/notes.md @@ -0,0 +1,35 @@ +# Tic Tac Toe notes + +## Checkpoint 1 Notes + +Which class is responsible for each of the following behaviors? +For each, explain how the behavior is accomplished. + +### Checking to see whether the game is over + +### Determining which actions are available at a particular state + +### Showing the board + +### Choosing which action to play on a turn + + +## Checkpoint 2 Notes + +### TTT Strategy + +For each of the following board states, if you are playing as X +and it's your turn, which action would you take? Why? + + | O | O | | O | X | X | O | +---+---+--- ---+---+--- ---+---+--- ---+---+--- + X | X | | X | X | O | O | | +---+---+--- ---+---+--- ---+---+--- ---+---+--- + | | | | O | | | | + +### Initial game state + +You can get the inital game state using game.get_initial_state(). +What is the current and future reward for this state? What does this mean? + + diff --git a/play_nim.py b/play_nim.py new file mode 100644 index 0000000..61f5cf2 --- /dev/null +++ b/play_nim.py @@ -0,0 +1,15 @@ +from nim.game_stub import NimGameStub +from nim.view import NimView +from nim.player import HumanNimPlayer, ComputerNimPlayer + +player0 = HumanNimPlayer(input("What's your name? ")) +player1 = ComputerNimPlayer("Robot") +view = NimView(player0, player1) +game = NimGameStub() + +view.greet() +state = game.get_initial_state() +while not game.is_over(state): + action = view.get_action(state) + state = game.get_next_state(state, action) +view.conclude(state) diff --git a/play_ttt.py b/play_ttt.py new file mode 100644 index 0000000..ef5530a --- /dev/null +++ b/play_ttt.py @@ -0,0 +1,15 @@ +from ttt.game import TTTGame +from ttt.view import TTTView +from ttt.player import TTTHumanPlayer, TTTComputerPlayer + +player0 = TTTHumanPlayer("Player 1") +player1 = TTTHumanPlayer("Player 2") +game = TTTGame() +view = TTTView(player0, player1) + +state = game.get_initial_state() +view.greet() +while not game.is_over(state): + action = view.get_action(state) + state = game.get_next_state(state, action) +view.conclude(state) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..ee665e2 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,33 @@ +# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "101b8706a8befcaae12f34f371e35e5bc371645d8ce2747a4b32cca44ff8e832" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..41263d3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[tool.poetry] +name = "lab_tic_tac_toe" +version = "0.1.0" +description = "" +authors = ["Chris Proctor "] +license = "MIT" + +[tool.poetry.dependencies] +python = "^3.11" +click = "^8.1.3" + +[tool.poetry.dev-dependencies] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/strategy/lookahead_strategy.py b/strategy/lookahead_strategy.py new file mode 100644 index 0000000..bd2183a --- /dev/null +++ b/strategy/lookahead_strategy.py @@ -0,0 +1,96 @@ +from types import MethodType +from random import choice + +class LookaheadStrategy: + """A Strategy which considers the future consequences of an action. + + To initialize a LookaheadStrategy, pass in an instance of a game containing + the following methods. These methods encode the rules of the game, + which a LookaheadStrategy needs to know in order to determine which move is best. + + - get_next_state: state, action -> state + - get_actions: state -> [actions] + - get_reward: state -> int + - is_over: state -> bool + - get_objective: str -> function + + Optionally, pass the following arguments to control the behavior of the LookaheadStrategy: + + - max_depth: int. A game may be too complex to search the full state tree. + Setting max_depth will set a cutoff on how far ahead the LookaheadStrategy will look. + - deterministic: bool. It's possible that there are multiple equally-good actions. + When deterministic is True, LookaheadStrategy will always choose the first of the + equally-good actions, so that LookaheadStrategy will always play out the same game. + When deterministic is False, LookaheadStrategy will choose randomly from all actions + which are equally-good. + - Explain: When set to True, LookaheadStrategy will print out its reasoning. + """ + + def __init__(self, game, max_depth=None, deterministic=True, explain=False): + self.validate_game(game) + self.game = game + self.max_depth = max_depth + self.deterministic = deterministic + self.explain = explain + + def choose_action(self, state, depth=0): + """Given a state, chooses an action. + This is the most important method of a Strategy, corresponding to the situation where + it's a player's turn to play a game and she needs to decide what to do. + + Strategy chooses an action by considering all possible actions, and finding the + total current and future reward which would come from playing that action. + Then we use the game's objective to choose the "best" reward. Usually bigger is better, + but in zero-sum games like tic tac toe, the players want opposite outcomes. One player + wants the reward to be high, while the other wants the reward to be low. + + Once we know which reward is best, we choose an action which will lead to that reward. + """ + possible_actions = self.game.get_actions(state) + rewards = {} + for action in possible_actions: + future_state = self.game.get_next_state(state, action) + rewards[action] = self.get_current_and_future_reward(future_state, depth=depth) + objective = self.game.get_objective(state) + best_reward = objective(rewards.values()) + best_actions = [action for action in possible_actions if rewards[action] == best_reward] + if self.deterministic: + action = best_actions[0] + else: + action = choice(best_actions) + if self.explain: + self.print_explanation(state, action, rewards[action], depth) + return action + + def get_current_and_future_reward(self, state, depth=0): + """Calculates the reward from this state, and from all future states which would be + reached, assuming all players are using this Strategy. + """ + reward = self.game.get_reward(state) + if (self.max_depth is None or depth <= self.max_depth) and not self.game.is_over(state): + action = self.choose_action(state, depth=depth+1) + future_state = self.game.get_next_state(state, action) + reward += self.get_current_and_future_reward(future_state, depth=depth+1) + return reward + + def validate_game(self, game): + "Checks that the game has all the required methods." + required_methods = [ + "get_next_state", + "get_actions", + "get_reward", + "is_over", + "get_objective", + ] + for method in required_methods: + if not (hasattr(game, method) and isinstance(getattr(game, method), MethodType)): + message = f"Game {game} does not have method {method}." + raise ValueError(message) + + def print_explanation(self, state, action, reward, depth): + """Prints out the current state of exploration of the state tree""" + indent = '│ ' * (max(0, depth-1)) + ('├ ' if depth > 0 else '') + print(f"{indent}[{reward}] Best action: {action} {state}") + + + diff --git a/strategy/random_strategy.py b/strategy/random_strategy.py new file mode 100644 index 0000000..e3b5816 --- /dev/null +++ b/strategy/random_strategy.py @@ -0,0 +1,11 @@ +from random import choice + +class RandomStrategy: + """A Strategy which randomly chooses a move. Not a great choice. + """ + def __init__(self, game): + self.game = game + + def choose_action(self, state): + possible_actions = self.game.get_actions(state) + return choice(possible_actions) diff --git a/ttt/game.py b/ttt/game.py new file mode 100644 index 0000000..2f0e302 --- /dev/null +++ b/ttt/game.py @@ -0,0 +1,61 @@ +class TTTGame: + "Models a tic-tac-toe game." + + def get_initial_state(self): + "Returns the game's initial state." + return { + "board": ['-', '-', '-', '-', '-', '-', '-', '-', '-'], + "player_x": True, + } + + def get_next_state(self, state, action): + """Given a state and an action, returns the resulting state. + In the resulting state, the current player's symbol has been placed + in an empty board space, and it is the opposite player's turn. + """ + new_board = state["board"].copy() + new_board[action] = 'X' if state["player_x"] else 'O' + return { + "board": new_board, + "player_x": not state["player_x"], + } + + def get_actions(self, state): + "Returns a list of the indices of empty spaces" + return [index for index in range(9) if state["board"][index] == '-'] + + def is_over(self, state): + "Checks whether the game is over." + return self.board_is_full(state) or self.check_winner(state, 'X') or self.check_winner(state, 'O') + + def get_reward(self, state): + """Determines the reward associated with reaching this state. + For tic-tac-toe, the two opponents each want a different game outcome. So + we set the reward for X winning to 1 and the reward for O winning to -1. + All other states (unfinished games and games which ended in a draw) are worth 0. + """ + if self.check_winner(state, 'X'): + return 1 + elif self.check_winner(state, 'O'): + return -1 + else: + return 0 + + def get_objective(self, state): + """Returns a player's objective, or a function describing what a player wants. + This function should choose the best value from a list. In tic tac toe, the players + want opposite things, so we set X's objective to the built-in function `max` + (which chooses the largest number), and we set O's objective to the built-in function `min`. + """ + return max if state["player_x"] else min + + def board_is_full(self, state): + "Checks whether all the spaces in the board are occupied." + for space in state["board"]: + if space == '-': + return False + return True + + def check_winner(self, state, symbol): + "Checks whether the player with `symbol` has won the game." + return False diff --git a/ttt/player.py b/ttt/player.py new file mode 100644 index 0000000..bfbbe15 --- /dev/null +++ b/ttt/player.py @@ -0,0 +1,33 @@ +from click import Choice, prompt +from strategy.random_strategy import RandomStrategy +from ttt.game import TTTGame +import random + +class TTTHumanPlayer: + "A human tic tac toe player." + + def __init__(self, name): + "Sets up the player." + self.name = name + self.game = TTTGame() + + def choose_action(self, state): + "Chooses an action by prompting the player for a choice." + actions = self.game.get_actions(state) + choices = Choice([str(action) for action in actions]) + action = int(prompt("> ", type=choices, show_choices=False)) + return action + +class TTTComputerPlayer: + "A computer tic tac toe player" + + def __init__(self, name): + "Sets up the player." + self.name = name + self.strategy = RandomStrategy(TTTGame()) + + def choose_action(self, state): + "Chooses a random move from the moves available." + action = self.strategy.choose_action(state) + print(f"{self.name} chooses {action}.") + return action diff --git a/ttt/view.py b/ttt/view.py new file mode 100644 index 0000000..30df0fd --- /dev/null +++ b/ttt/view.py @@ -0,0 +1,75 @@ +from ttt.game import TTTGame +import click + +class TTTView: + "Handles user interaction with a tic-tac-toe game." + greeting = "Welcome to tic-tac-toe" + goodbye = "Well, that's a wrap." + divider = "---+---+---" + x_color = "red" + o_color = "blue" + option_color = "bright_black" + + def __init__(self, playerX, playerO): + self.game = TTTGame() + self.players = { + "X": playerX, + "O": playerO, + } + + def greet(self): + "Starts a new game by greeting the players." + x_name = self.players['X'].name + o_name = self.players['O'].name + print(self.greeting) + print(f"{x_name} will play as X.") + print(f"{o_name} will play as O.") + + def get_action(self, state): + "Shows the board and asks the current player for their choice of action." + self.print_board(state) + current_player_symbol = 'X' if state["player_x"] else 'O' + player = self.players[current_player_symbol] + print(f"{player.name}, it's your move.") + return player.choose_action(state) + + def print_board(self, state): + "Prints the current board, showing indices of available spaces" + print(self.format_row(state, [0, 1, 2])) + print(self.divider) + print(self.format_row(state, [3, 4, 5])) + print(self.divider) + print(self.format_row(state, [6, 7, 8])) + + def format_row(self, state, indices): + "Returns a string for one row in the board, like ' X | O | X '" + spaces = [self.format_value(state, i) for i in indices] + return f" {spaces[0]} | {spaces[1]} | {spaces[2]} " + + def format_value(self, state, index): + """Formats the value for a single space on the board. + If the game board already has a symbol in that space, formats that value for the Terminal. + If the space is empty, instead formats the index of the space. + """ + if state["board"][index] == 'X': + return click.style('X', fg=self.x_color) + elif state["board"][index] == 'O': + return click.style('O', fg=self.o_color) + else: + return click.style(index, fg=self.option_color) + + def conclude(self, state): + """Says goodbye. + """ + self.print_board(state) + if self.game.check_winner(state, 'X'): + winner = self.players['X'] + elif self.game.check_winner(state, 'O'): + winner = self.players['O'] + else: + winner = None + print(self.goodbye) + if winner: + print(f"Congratulations to {winner.name}.") + else: + print("Nobody won this game.")