First commit: minmax and alphabeta implemented
This commit is contained in:
commit
45abd8c7eb
7 changed files with 593 additions and 0 deletions
48
README.md
Normal file
48
README.md
Normal 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
1
src/.envrc
Normal file
|
@ -0,0 +1 @@
|
|||
use nix
|
24
src/classes/CustomFormater.py
Normal file
24
src/classes/CustomFormater.py
Normal 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
187
src/classes/Engines.py
Normal 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
232
src/classes/Reversi.py
Normal 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
93
src/game.py
Executable 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
8
src/shell.nix
Normal file
|
@ -0,0 +1,8 @@
|
|||
with (import <nixpkgs> {});
|
||||
mkShell {
|
||||
buildInputs = [
|
||||
|
||||
# Defines a python + set of packages.
|
||||
python3
|
||||
];
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue