Files
CSC111/assignments/A2/a2_minichess.py
T
2022-02-10 18:43:51 -05:00

484 lines
18 KiB
Python

"""CSC111 Winter 2021 Assignment 2: Trees, Chess, and Artificial Intelligence (Minichess Library)
Module Description
==================
This module contains a collection of Python classes and functions that you'll use on
this assignment to represent games of Minichess. You are responsible for reading the
*docstrings* of this file to understand how to use these classes and functions,
but should not modify anything in this file. It will not be submitted, and we will
supply our own copy for grading purposes.
Note: as is standard for CSC111, we use a leading underscore to indicate private
functions, methods, and instance attributes. You don't have to worry about any of these,
and in fact shouldn't use them in this assignment!
Disclaimer: we didn't have time to make this file fully PythonTA-compliant!
Copyright and Usage Information
===============================
This file is provided solely for the personal and private use of students
taking CSC111 at the University of Toronto St. George campus. All forms of
distribution of this code, whether as given or with any changes, are
expressly prohibited. For more information on copyright for CSC111 materials,
please consult our Course Syllabus.
This file is Copyright (c) 2022 Mario Badr, David Liu, and Isaac Waller.
"""
from __future__ import annotations
import copy
import random
import time
from typing import Optional
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import chessboard.display as display
################################################################################
# Representing Minichess
################################################################################
_FILE_TO_INDEX = {'a': 0, 'b': 1, 'c': 2, 'd': 3}
_INDEX_TO_FILE = {i: f for f, i in _FILE_TO_INDEX.items()}
_RANK_TO_INDEX = {'1': 0, '2': 1, '3': 2, '4': 3}
_INDEX_TO_RANK = {i: r for r, i in _RANK_TO_INDEX.items()}
_MAX_MOVES = 50
class MinichessGame:
"""A class representing a state of a game of Minichess.
>>> game = MinichessGame()
>>> # Get all valid moves for white at the start of the game.
>>> game.get_valid_moves()
['a2b3', 'b2c3', 'b2a3', 'c2d3', 'c2b3', 'd2c3']
>>> # Make a move. This method mutates the state of the game.
>>> game.make_move('a2b3')
>>> game.get_valid_moves() # Now, black only has one valid move
['b4b3']
>>> # If you try to make an invalid move, a ValueError is raised.
>>> game.make_move('a4d1')
Traceback (most recent call last):
ValueError: Move "a4d1" is not valid
>>> # This move is okay.
>>> game.make_move('b4b3')
>>> game.get_url()
'https://lichess.org/analysis/standard/8/8/8/8/r1kr4/pqpp4/1PPP4/RQKR4'
"""
# Private Instance Attributes:
# - _board: a two-dimensional representation of a Minichess board
# - _valid_moves: a list of the valid moves of the current player
# - _is_white_active: a boolean representing whether white is the current player
# - _move_count: the number of moves that have been made in the current game
_board: list[list[Optional[_Piece]]]
_valid_moves: list[str]
_is_white_active: bool
_move_count: int
def __init__(self, board: list[list[Optional[_Piece]]] = None,
white_active: bool = True, move_count: int = 0) -> None:
if board is not None:
self._board = board
else:
self._board = [
[_Piece('r', True), _Piece('q', True), _Piece('k', True), _Piece('r', True)],
[_Piece('p', True), _Piece('p', True), _Piece('p', True), _Piece('p', True)],
[_Piece('p', False), _Piece('p', False), _Piece('p', False), _Piece('p', False)],
[_Piece('r', False), _Piece('q', False), _Piece('k', False), _Piece('r', False)]
]
self._is_white_active = white_active
self._move_count = move_count
self._valid_moves = []
self._recalculate_valid_moves()
def get_valid_moves(self) -> list[str]:
"""Return a list of the valid moves for the active player."""
return self._valid_moves
def make_move(self, move: str) -> None:
"""Make the given chess move. This instance of Minichess will be mutated, and will
afterwards represent the game state after move is made.
If move is not a currently valid move, raise a ValueError.
"""
if move not in self._valid_moves:
raise ValueError(f'Move "{move}" is not valid')
self._board = self._board_after_move(move)
self._is_white_active = not self._is_white_active
self._move_count += 1
self._recalculate_valid_moves()
def copy_and_make_move(self, move: str) -> MinichessGame:
"""Make the given chess move in a copy of this MinichessGame, and return that copy.
If move is not a currently valid move, raise a ValueError.
"""
if move not in self._valid_moves:
raise ValueError(f'Move "{move}" is not valid')
return MinichessGame(board=self._board_after_move(move),
white_active=not self._is_white_active,
move_count=self._move_count + 1)
def is_white_move(self) -> bool:
"""Return whether the white player is to move next."""
return self._is_white_active
def get_winner(self) -> Optional[str]:
"""Return the winner of the game (black or white) or 'draw' if the game ended in a draw.
Return None if the game is not over.
"""
if self._move_count >= _MAX_MOVES:
return 'Draw'
elif len(self._valid_moves) == 0:
return 'Black' if self._is_white_active else 'White'
else:
return None
def _calculate_moves_for_board(self, board: list[list[Optional[_Piece]]],
is_white_active: bool) -> tuple:
"""Return all possible moves on a given board with a given active player."""
moves = []
# Used to calculate whether the other players' king is in check
# (i.e. the black king if is_white_active, otherwise the white king)
check = []
for pos in [(y, x) for y in range(0, 4) for x in range(0, 4)]:
piece = board[pos[0]][pos[1]]
if piece is None or piece.is_white != is_white_active:
continue
kind, is_white = piece.kind, piece.is_white
if kind == 'p':
# Pawns can only move towards the opponent's end of the board.
direction = 1 if is_white else -1
check += self._find_moves_in_direction(board, moves, pos, is_white, (direction, 0),
limit=1, capture=False)
check += self._find_moves_in_direction(board, moves, pos, is_white, (direction, 1),
limit=1, capture=True)
check += self._find_moves_in_direction(board, moves, pos, is_white, (direction, -1),
limit=1, capture=True)
if kind == 'r' or kind == 'q':
check += self._find_moves_in_direction(board, moves, pos, is_white, (0, 1))
check += self._find_moves_in_direction(board, moves, pos, is_white, (1, 0))
check += self._find_moves_in_direction(board, moves, pos, is_white, (0, -1))
check += self._find_moves_in_direction(board, moves, pos, is_white, (-1, 0))
if kind == 'q':
for y, x in [(y, x) for y in [-1, 1] for x in [-1, 1]]:
check += self._find_moves_in_direction(board, moves, pos, is_white, (y, x))
if kind == 'k':
for y, x in [(y, x) for y in [-1, 0, 1] for x in [-1, 0, 1]]:
check += self._find_moves_in_direction(board, moves, pos, is_white, (y, x),
limit=1)
return moves, check
def _find_moves_in_direction(self, board, moves, pos, is_white, direction, limit=None,
capture=None):
"""Find valid moves moving in a given direction from a certain position.
capture: True if must capture, False if must not capture, None otherwise.
"""
move_start = _index_to_algebraic(pos)
stop = False
i = 1
check = []
while not stop:
y, x = pos[0] + direction[0] * i, pos[1] + direction[1] * i
if x < 0 or y < 0 or x > 3 or y > 3:
break # Out of bounds
contents = board[y][x]
move = move_start + _index_to_algebraic((y, x))
if contents is not None:
# Square contains piece
stop = True
if contents.is_white != is_white and contents.kind == 'k' \
and capture is not False:
# Cannot capture king, but they are in check
check.append(move)
elif contents.is_white != is_white and capture is not False:
# Capture
moves.append(move)
else:
# Empty square
if capture is not True:
moves.append(move)
i += 1
if limit is not None and i > limit:
stop = True
return check
def _board_after_move(self, move: str) -> list[list[Optional[_Piece]]]:
"""Return a copy of self._board representing the state of the board after making move.
"""
board_copy = copy.deepcopy(self._board)
start_pos = _algebraic_to_index(move[0:2])
end_pos = _algebraic_to_index(move[2:])
board_copy[end_pos[0]][end_pos[1]] = board_copy[start_pos[0]][start_pos[1]]
board_copy[start_pos[0]][start_pos[1]] = None
return board_copy
def _recalculate_valid_moves(self) -> None:
"""Update the valid moves for this game board."""
moves, check = self._calculate_moves_for_board(self._board, self._is_white_active)
assert len(check) == 0, \
"The other player's king can never be in check at the start of your turn."
# Filter moves that would leave the current player's king in check
valid_moves = []
for move in moves:
board_copy = self._board_after_move(move)
_, check = self._calculate_moves_for_board(board_copy, not self._is_white_active)
if len(check) == 0:
valid_moves.append(move)
self._valid_moves = valid_moves
def get_fen(self) -> str:
"""Return a string description of the current game state in Forsyth-Edwards Notation.
Reference: https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notation.
This method is used to visualize the game board using Pygame---you won't need to call it
directly.
"""
rows = [''.join([(p.fen() if p is not None else '1') for p in row]) + '4' for row in
self._board]
return '/'.join(reversed(rows))
def get_url(self) -> str:
"""Return a URL to a web page where you can examine the current state of the board."""
return "https://lichess.org/analysis/standard/8/8/8/8/" + self.get_fen()
def _algebraic_to_index(move: str) -> tuple[int, int]:
"""Convert coordinates in algebraic format ex. 'a2' to array indices (y, x)."""
return (_RANK_TO_INDEX[move[1]], _FILE_TO_INDEX[move[0]])
def _index_to_algebraic(pos: tuple[int, int]) -> str:
"""Convert coordinates in array indices (y, x) to algebraic format."""
return _INDEX_TO_FILE[pos[1]] + _INDEX_TO_RANK[pos[0]]
class _Piece:
"""Represents a single piece in Minichess.
Instance Attributes:
- kind: the type of piece
- is_white: whether the piece belongs to the white player
"""
kind: str # One of 'rqkp' (rook, queen, king, pawn)
is_white: bool
def __init__(self, kind: str, is_white: bool) -> None:
"""Initialize a new piece."""
self.kind = kind
self.is_white = is_white
def fen(self) -> str:
"""Return the string representing this piece in FEN."""
if self.is_white:
return self.kind.upper()
else:
return self.kind
def __str__(self) -> str:
return self.fen()
################################################################################
# Chess player classes
################################################################################
class Player:
"""An abstract class representing a Minichess AI.
This class can be subclassed to implement different strategies for playing chess.
"""
def make_move(self, game: MinichessGame, previous_move: Optional[str]) -> str:
"""Make a move given the current game.
previous_move is the opponent player's most recent move, or None if no moves
have been made.
Preconditions:
- There is at least one valid move for the given game
"""
raise NotImplementedError
class RandomPlayer(Player):
"""A Minichess AI whose strategy is always picking a random move."""
def make_move(self, game: MinichessGame, previous_move: Optional[str]) -> str:
"""Make a move given the current game.
previous_move is the opponent player's most recent move, or None if no moves
have been made.
Preconditions:
- There is at least one valid move for the given game
"""
possible_moves = game.get_valid_moves()
return random.choice(possible_moves)
################################################################################
# Functions for running games
################################################################################
DEFAULT_FPS = 6 # Default number of moves per second to display in the visualization
def run_games(n: int, white: Player, black: Player,
visualize: bool = False, fps: int = DEFAULT_FPS,
show_stats: bool = False) -> None:
"""Run n games using the given Players.
Optional arguments:
- visualize: whether to use Pygame to visualize the games
- fps: the number of moves per second to display (only relevant if visualize is True)
- show_stats: whether to use Plotly to display statistics for the game runs
Preconditions:
- n >= 1
- fps >= 1
"""
if visualize:
_initialize_display()
stats = {'White': 0, 'Black': 0, 'Draw': 0}
results = []
for i in range(0, n):
white_copy = copy.deepcopy(white)
black_copy = copy.deepcopy(black)
winner, _ = run_game(white_copy, black_copy, visualize, fps)
stats[winner] += 1
results.append(winner)
print(f'Game {i} winner: {winner}')
for outcome in stats:
print(f'{outcome}: {stats[outcome]}/{n} ({100.0 * stats[outcome] / n:.2f}%)')
if visualize:
_terminate_display()
if show_stats:
plot_game_statistics(results)
def run_game(white: Player, black: Player,
visualize: bool = False, fps: int = DEFAULT_FPS) -> tuple[str, list[str]]:
"""Run a Minichess game between the two given players.
Return the winner and list of moves made in the game.
"""
game = MinichessGame()
move_sequence = []
previous_move = None
current_player = white
while game.get_winner() is None:
previous_move = current_player.make_move(game, previous_move)
game.make_move(previous_move)
move_sequence.append(previous_move)
if visualize:
display.update(game.get_fen(), game.get_winner())
time.sleep(1 / fps)
if current_player is white:
current_player = black
else:
current_player = white
if visualize:
# Give slightly more time to the victory visualization
time.sleep(4 / fps)
return game.get_winner(), move_sequence
def _initialize_display() -> None:
"""Initialize the Minichess visualization pygame window."""
display.start('8/8/8/8', size=4)
def _terminate_display() -> None:
"""Close the Minichess visualization pygame window."""
display.terminate()
def plot_game_statistics(results: list[str]) -> None:
"""Plot the outcomes and win probabilities for a given list of Minichess game results.
Preconditions:
- all(r in {'White', 'Black', 'Draw'} for r in results)
"""
outcomes = [1 if result == 'White' else 0 for result in results]
cumulative_win_probability = [sum(outcomes[0:i]) / i for i in range(1, len(outcomes) + 1)]
rolling_win_probability = \
[sum(outcomes[max(i - 50, 0):i]) / min(50, i) for i in range(1, len(outcomes) + 1)]
fig = make_subplots(rows=2, cols=1)
fig.add_trace(go.Scatter(y=outcomes, mode='markers',
name='Outcome (1 = White win, 0 = Draw/Black win)'),
row=1, col=1)
fig.add_trace(go.Scatter(y=cumulative_win_probability, mode='lines',
name='White win percentage (cumulative)'),
row=2, col=1)
fig.add_trace(go.Scatter(y=rolling_win_probability, mode='lines',
name='White win percentage (most recent 50 games)'),
row=2, col=1)
fig.update_yaxes(range=[0.0, 1.0], row=2, col=1)
fig.update_layout(title='Minichess Game Results', xaxis_title='Game')
fig.show()
# fig.write_image('stats.png')
if __name__ == '__main__':
import doctest
doctest.testmod()
# Demo running Minichess games being played between two random players
# run_games(100, RandomPlayer(), RandomPlayer(), show_stats=True)
# Try running this to visualize games (takes longer)
# run_games(20, RandomPlayer(), RandomPlayer(), visualize=True, fps=10, show_stats=True)