First commit: minmax and alphabeta implemented

This commit is contained in:
Yorick Barbanneau 2023-12-14 21:58:35 +01:00
commit 45abd8c7eb
7 changed files with 593 additions and 0 deletions

48
README.md Normal file
View file

@ -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.

1
src/.envrc Normal file
View file

@ -0,0 +1 @@
use nix

View file

@ -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)

187
src/classes/Engines.py Normal file
View file

@ -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

232
src/classes/Reversi.py Normal file
View file

@ -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__

93
src/game.py Executable file
View file

@ -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__)

8
src/shell.nix Normal file
View file

@ -0,0 +1,8 @@
with (import <nixpkgs> {});
mkShell {
buildInputs = [
# Defines a python + set of packages.
python3
];
}