From 45abd8c7ebc3ef824c9d2195c8c95b5c5e1f35ae Mon Sep 17 00:00:00 2001 From: Yorick Barbanneau Date: Thu, 14 Dec 2023 21:58:35 +0100 Subject: [PATCH] First commit: minmax and alphabeta implemented --- README.md | 48 +++++++ src/.envrc | 1 + src/classes/CustomFormater.py | 24 ++++ src/classes/Engines.py | 187 +++++++++++++++++++++++++++ src/classes/Reversi.py | 232 ++++++++++++++++++++++++++++++++++ src/game.py | 93 ++++++++++++++ src/shell.nix | 8 ++ 7 files changed, 593 insertions(+) create mode 100644 README.md create mode 100644 src/.envrc create mode 100644 src/classes/CustomFormater.py create mode 100644 src/classes/Engines.py create mode 100644 src/classes/Reversi.py create mode 100755 src/game.py create mode 100644 src/shell.nix diff --git a/README.md b/README.md new file mode 100644 index 0000000..44b9f12 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +IA: jeu de Reversi +------------------ + +Le but de ce projet est d'implémenter plusieurs mécanisme de jeu (humain et +intelligence artificielle) pour le jeu de Reversi + +## Installation + +Le programme utilise des outils standard de Python installé de base : `random`, +`math`, `argpase` et `logging`. Le project est fourni avec un shell *Nix* dans +le répertoire `src` + +## Utilisation + +le programme propose un emsemble d'options en ligne de commande afin de définir +les options du jeu comme le choix des implementations de jeu (aléatoine, MinMax +etc.) ou encore les paramètres (profondeur de recherche). Une aide est intégrée +au programme via la commande `./game.py -h`. Voici quelques exemple de +lancement: + +```shell +# Lancement de base: les deux joueurs jouent avec le moteur aléatoire: +./game.py + +# joueur noir humain et joueur blanc MinMax avec une profondeur de 5 +./game.py -b human -w minmax --depth 5 +``` + +## Choix d'implémentation + +### Classes PlayerEngine + +Définies dans le fichier `./src/classes/Engines.py`, les classes utilisées h +eritent de la classe de base `PlayerEngines` : + +```python +class PlayerEngine(Object): + def __init__(self, logger, options): + def get_move(self, board): + +class MinmaxPlayerEngine(PlayerEngine): + def get_move(board): + +class RandomPlayerEngnine(PlayerEngine): + def get_move(board): +``` + +Il est ainsi plus aisé de tester les moteur dans notre programme de base. diff --git a/src/.envrc b/src/.envrc new file mode 100644 index 0000000..1d953f4 --- /dev/null +++ b/src/.envrc @@ -0,0 +1 @@ +use nix diff --git a/src/classes/CustomFormater.py b/src/classes/CustomFormater.py new file mode 100644 index 0000000..02ad065 --- /dev/null +++ b/src/classes/CustomFormater.py @@ -0,0 +1,24 @@ +import logging + +class CustomFormatter(logging.Formatter): + + grey = "\x1b[0;35m" + blue = "\x1b[34;20m" + yellow = "\x1b[33;20m" + red = "\x1b[31;20m" + bold_red = "\x1b[31;1m" + reset = "\x1b[0m" + format = "%(levelname)s: %(message)s (%(filename)s:%(lineno)d)" + + FORMATS = { + logging.DEBUG: blue + format + reset, + logging.INFO: grey + format + reset, + logging.WARNING: yellow + format + reset, + logging.ERROR: red + format + reset, + logging.CRITICAL: bold_red + format + reset + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno) + formatter = logging.Formatter(log_fmt) + return formatter.format(record) diff --git a/src/classes/Engines.py b/src/classes/Engines.py new file mode 100644 index 0000000..c3b4ea5 --- /dev/null +++ b/src/classes/Engines.py @@ -0,0 +1,187 @@ +import random, math + +class PlayerEngine: + def __init__(self, logger, options: dict = {}): + # init logger do display informations + self.logger = logger + self.options = options + self.logger.info("Init engine {}".format(self.__class__.__name__)) + + def get_move(self, board): + self.logger.info("engine: {} - player:{}".format( + self.__class__.__name__, + self.get_player_name(board._nextPlayer) + )) + + @staticmethod + def get_player_name(player): + return 'White (O)' if player is 2 else 'Black (X)' + +class RandomPlayerEngine(PlayerEngine): + def get_move(self, board): + super().get_move(board) + moves = board.legal_moves() + return random.choice(moves) + +class HumanPlayerEngine(PlayerEngine): + def get_move(self, board): + super() + move = None + while move is None: + user_input = input("Please enter player {} move: ".format( + self.get_player_name(board._nextPlayer) + )) + move = self.validate_input(user_input, board, player) + print("{}".format(move)) + return move + + @staticmethod + def validate_input(input, board): + if input == 'print': + print("\n{}".format(board.__str__)) + return None + + if input == 'help': + print('{}'.format(board.legal_moves())) + return None + + if len(input) != 2: + return None + + x = int(input[0]) + y = int(input[1]) + if not board.is_valid_move(int(player), x, y): + return None + + return [board._nextPlayer, x, y] + +class MinmaxPlayerEngine(PlayerEngine): + def get_move(self, board): + super().get_move(board) + value = -math.inf + nodes = 1 + leafs = 0 + move = '' + for m in board.legal_moves(): + board.push(m) + v, n, l = self.checkMinMax(board, False, self.options['depth'] - 1) + if v > value: + value = v + move = m + self.logger.debug("found a better move: {} (heuristic:{})".format( + move, + value + )) + nodes += n + leafs += l + board.pop() + + self.logger.debug("Tree statistics:\n\tnodes:{}\n\tleafs:{}".format( + nodes, + leafs + )) + return move + + def checkMinMax(self, board, friend_move:bool, depth :int = 2): + nodes = 1 + leafs = 0 + move = '' + if depth == 0: + leafs +=1 + return board.heuristique(), nodes, leafs + + if friend_move: + value = -math.inf + for m in board.legal_moves(): + board.push(m) + v, n, l = self.checkMinMax(board, False, depth - 1) + if v > value: + value = v + nodes += n + leafs += l + board.pop() + + else: + value = math.inf + for m in board.legal_moves(): + board.push(m) + v, n, l = self.checkMinMax(board, True, depth - 1) + if v < value: + value = v + board.pop(); + nodes += n + leafs += l + return value, nodes, leafs + +class AlphabetaPlayerEngine(PlayerEngine): + + def get_move(self, board): + super().get_move(board) + alpha = -math.inf + beta = math.inf + nodes = 1 + leafs = 0 + move = [] + value = -math.inf + for m in board.legal_moves(): + board.push(m) + v, n, l = self.checkAlphaBeta(board, False, self.options['depth'] - 1, alpha, beta) + board.pop() + alpha = max(alpha,v) + nodes += n + leafs += l + if alpha >= value: + value = alpha + move = m + self.logger.debug("found a better move: {} (heuristic:{})".format( + move, + alpha + )) + + self.logger.debug("Tree statistics:\n\tnodes:{}\n\tleafs:{}".format( + nodes, + leafs + )) + return move + + + def checkAlphaBeta(self, board, friend_move : bool, depth, alpha, beta): + nodes = 1 + leafs = 0 + if depth == 0 : + leafs +=1 + return board.heuristique(), nodes, leafs + + if friend_move: + value = -math.inf + for m in board.legal_moves(): + board.push(m) + v, n, l = self.checkAlphaBeta(board, False, depth - 1, alpha, beta) + board.pop() + alpha = max(value,v) + nodes += n + leafs += l + if alpha >= beta: + self.logger.debug("Alpha pruning - alpha:{} / beta:{}".format( + alpha, + beta + )) + return beta, nodes, leafs + return alpha, nodes, leafs + + else: + value = math.inf + for m in board.legal_moves(): + board.push(m) + v, n, l = self.checkAlphaBeta(board, True, depth - 1, alpha, beta) + board.pop(); + beta = min(beta,v) + nodes += n + leafs += l + if alpha >= beta: + self.logger.debug("Beta pruning - alpha:{} / beta:{}".format( + alpha, + beta + )) + return alpha, nodes, leafs + return beta, nodes, leafs diff --git a/src/classes/Reversi.py b/src/classes/Reversi.py new file mode 100644 index 0000000..0d4d72d --- /dev/null +++ b/src/classes/Reversi.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- + +''' Fichier de règles du Reversi + Certaines parties de ce code sont fortement inspirée de + https://inventwithpython.com/chapter15.html + + ''' + +class Board: + _BLACK = 1 + _WHITE = 2 + _EMPTY = 0 + + # Attention, la taille du plateau est donnée en paramètre + def __init__(self, boardsize = 8): + self._nbWHITE = 2 + self._nbBLACK = 2 + self._nextPlayer = self._BLACK + self._boardsize = boardsize + self._board = [] + for x in range(self._boardsize): + self._board.append([self._EMPTY]* self._boardsize) + _middle = int(self._boardsize / 2) + self._board[_middle-1][_middle-1] = self._BLACK + self._board[_middle-1][_middle] = self._WHITE + self._board[_middle][_middle-1] = self._WHITE + self._board[_middle][_middle] = self._BLACK + + self._stack= [] + self._successivePass = 0 + + def reset(self): + self.__init__() + + # Donne la taille du plateau + def get_board_size(self): + return self._boardsize + + # Donne le nombre de pieces de blanc et noir sur le plateau + # sous forme de tuple (blancs, noirs) + # Peut être utilisé si le jeu est terminé pour déterminer le vainqueur + def get_nb_pieces(self): + return (self._nbWHITE, self._nbBLACK) + + # Vérifie si player a le droit de jouer en (x,y) + def is_valid_move(self, player, x, y): + if x == -1 and y == -1: + return not self.at_least_one_legal_move(player) + return self.lazyTest_ValidMove(player,x,y) + + def _isOnBoard(self,x,y): + return x >= 0 and x < self._boardsize and y >= 0 and y < self._boardsize + + # Renvoie la liste des pieces a retourner si le coup est valide + # Sinon renvoie False + # Ce code est très fortement inspiré de https://inventwithpython.com/chapter15.html + # y faire référence dans tous les cas + def testAndBuild_ValidMove(self, player, xstart, ystart): + if self._board[xstart][ystart] != self._EMPTY or not self._isOnBoard(xstart, ystart): + return False + + self._board[xstart][ystart] = player # On pourra remettre _EMPTY ensuite + + otherPlayer = self._flip(player) + + tilesToFlip = [] # Si au moins un coup est valide, on collecte ici toutes les pieces a retourner + for xdirection, ydirection in [[0, 1], [1, 1], [1, 0], [1, -1], [0, -1], [-1, -1], [-1, 0], [-1, 1]]: + x, y = xstart, ystart + x += xdirection + y += ydirection + if self._isOnBoard(x, y) and self._board[x][y] == otherPlayer: + # There is a piece belonging to the other player next to our piece. + x += xdirection + y += ydirection + if not self._isOnBoard(x, y): + continue + while self._board[x][y] == otherPlayer: + x += xdirection + y += ydirection + if not self._isOnBoard(x, y): # break out of while loop, then continue in for loop + break + if not self._isOnBoard(x, y): + continue + if self._board[x][y] == player: # We are sure we can at least build this move. Let's collect + while True: + x -= xdirection + y -= ydirection + if x == xstart and y == ystart: + break + tilesToFlip.append([x, y]) + + self._board[xstart][ystart] = self._EMPTY # restore the empty space + if len(tilesToFlip) == 0: # If no tiles were flipped, this is not a valid move. + return False + return tilesToFlip + + # Pareil que ci-dessus mais ne revoie que vrai / faux (permet de tester plus rapidement) + def lazyTest_ValidMove(self, player, xstart, ystart): + if self._board[xstart][ystart] != self._EMPTY or not self._isOnBoard(xstart, ystart): + return False + + self._board[xstart][ystart] = player # On pourra remettre _EMPTY ensuite + + otherPlayer = self._flip(player) + + for xdirection, ydirection in [[0, 1], [1, 1], [1, 0], [1, -1], [0, -1], [-1, -1], [-1, 0], [-1, 1]]: + x, y = xstart, ystart + x += xdirection + y += ydirection + if self._isOnBoard(x, y) and self._board[x][y] == otherPlayer: + # There is a piece belonging to the other player next to our piece. + x += xdirection + y += ydirection + if not self._isOnBoard(x, y): + continue + while self._board[x][y] == otherPlayer: + x += xdirection + y += ydirection + if not self._isOnBoard(x, y): # break out of while loop, then continue in for loop + break + if not self._isOnBoard(x, y): # On a au moins + continue + if self._board[x][y] == player: # We are sure we can at least build this move. + self._board[xstart][ystart] = self._EMPTY + return True + + self._board[xstart][ystart] = self._EMPTY # restore the empty space + return False + + def _flip(self, player): + if player == self._BLACK: + return self._WHITE + return self._BLACK + + def is_game_over(self): + if self.at_least_one_legal_move(self._nextPlayer): + return False + if self.at_least_one_legal_move(self._flip(self._nextPlayer)): + return False + return True + + def push(self, move): + [player, x, y] = move + assert player == self._nextPlayer + if x==-1 and y==-1: # pass + self._nextPlayer = self._flip(player) + self._stack.append([move, self._successivePass, []]) + self._successivePass += 1 + return + toflip = self.testAndBuild_ValidMove(player,x,y) + self._stack.append([move, self._successivePass, toflip]) + self._successivePass = 0 + self._board[x][y] = player + for xf,yf in toflip: + self._board[xf][yf] = self._flip(self._board[xf][yf]) + if player == self._BLACK: + self._nbBLACK += 1 + len(toflip) + self._nbWHITE -= len(toflip) + self._nextPlayer = self._WHITE + else: + self._nbWHITE += 1 + len(toflip) + self._nbBLACK -= len(toflip) + self._nextPlayer = self._BLACK + + def pop(self): + [move, self._successivePass, toflip] = self._stack.pop() + [player,x,y] = move + self._nextPlayer = player + if len(toflip) == 0: # pass + assert x == -1 and y == -1 + return + self._board[x][y] = self._EMPTY + for xf,yf in toflip: + self._board[xf][yf] = self._flip(self._board[xf][yf]) + if player == self._BLACK: + self._nbBLACK -= 1 + len(toflip) + self._nbWHITE += len(toflip) + else: + self._nbWHITE -= 1 + len(toflip) + self._nbBLACK += len(toflip) + + # Est-ce que on peut au moins jouer un coup ? + # Note: cette info pourrait être codée plus efficacement + def at_least_one_legal_move(self, player): + for x in range(0,self._boardsize): + for y in range(0,self._boardsize): + if self.lazyTest_ValidMove(player, x, y): + return True + return False + + # Renvoi la liste des coups possibles + # Note: cette méthode pourrait être codée plus efficacement + def legal_moves(self): + moves = [] + for x in range(0,self._boardsize): + for y in range(0,self._boardsize): + if self.lazyTest_ValidMove(self._nextPlayer, x, y): + moves.append([self._nextPlayer,x,y]) + if len(moves) is 0: + moves = [[self._nextPlayer, -1, -1]] # We shall pass + return moves + + # Exemple d'heuristique tres simple : compte simplement les pieces + def heuristique(self, player=None): + if player is None: + player = self._nextPlayer + if player is self._WHITE: + return self._nbWHITE - self._nbBLACK + return self._nbBLACK - self._nbWHITE + + def _piece2str(self, c): + if c==self._WHITE: + return 'O' + elif c==self._BLACK: + return 'X' + else: + return '.' + + def __str__(self): + toreturn="" + for l in self._board: + for c in l: + toreturn += self._piece2str(c) + toreturn += "\n" + toreturn += "Next player: " + ("BLACK" if self._nextPlayer == self._BLACK else "WHITE") + "\n" + toreturn += str(self._nbBLACK) + " blacks and " + str(self._nbWHITE) + " whites on board\n" + toreturn += "(successive pass: " + str(self._successivePass) + " )" + return toreturn + + __repr__ = __str__ + + diff --git a/src/game.py b/src/game.py new file mode 100755 index 0000000..027ab8d --- /dev/null +++ b/src/game.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +from classes.Reversi import Board +from classes.Engines import RandomPlayerEngine, HumanPlayerEngine, MinmaxPlayerEngine, AlphabetaPlayerEngine +import logging as log +import argparse as arg +from classes.CustomFormater import CustomFormatter + + +""" +Function to parse command line arguments +""" +def parse_aguments(): + engines_choices=['random', 'human', 'minmax', 'alphabeta'] + parser = arg.ArgumentParser('Playing Reversi with (virtual) friend') + + parser.add_argument('-w', '--white-engine', + choices=engines_choices, + help='white player engine (random)', + default='random' + ) + + parser.add_argument('-b', '--black-engine', + choices=engines_choices, + help='black player engine (random)', + default='random' + ) + + parser.add_argument('-D', '--depth', + help='Minmax exploration depth', + type=int, + default=3, + ) + + debug_group = parser.add_mutually_exclusive_group() + debug_group.add_argument('-V', '--verbose', + help='Verbose output', + action='store_true') + debug_group.add_argument('-d', '--debug', + help='Activate debug mode', + action='store_true') + return parser.parse_args() + + +""" +Main Function +""" +if __name__ == '__main__': + engines = { + "random": RandomPlayerEngine, + "human": HumanPlayerEngine, + "minmax": MinmaxPlayerEngine, + "alphabeta": AlphabetaPlayerEngine, + } + print("Stating PyReverso...") + args = parse_aguments() + logger = log.getLogger() + print(args) + # Create handler for streaming to tty (stderr / stdout) + tty_handler = log.StreamHandler() + tty_handler.setFormatter(CustomFormatter()) + logger.addHandler(tty_handler) + + # Activate verbose or debug mode + if args.verbose is True: + logger.setLevel(log.INFO) + logger.info('VERBOSE mode activated') + + if args.debug is True: + logger.setLevel(log.DEBUG) + logger.debug('DEBUG mode activated') + + game = Board(10) + logger.debug("Init players engines - black:{} / white:{}".format( + args.black_engine, + args.white_engine + )) + options = { + 'depth': args.depth + } + wplayer = engines[args.white_engine](logger, options) + bplayer = engines[args.black_engine](logger, options) + while ( not game.is_game_over()): + if game._nextPlayer == 1: + move = bplayer.get_move(game) + else: + move = wplayer.get_move(game) + print("Player {} move: {}".format( + game._nextPlayer, + move + )) + game.push(move) + print("Game end - score black:{}, white:{}".format(game.heuristique(1), game.heuristique(2))) + print(game.__str__) diff --git a/src/shell.nix b/src/shell.nix new file mode 100644 index 0000000..55375ea --- /dev/null +++ b/src/shell.nix @@ -0,0 +1,8 @@ +with (import {}); +mkShell { + buildInputs = [ + + # Defines a python + set of packages. + python3 + ]; +}