-
-
Save GeoffRiley/84a4c52af105062c6620cb405da6660c to your computer and use it in GitHub Desktop.
Naughts and Crosses implementation with automation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
Proper name: 'Naughts and Crosses' | |
Irish name: 'Xs and Os' | |
American name: 'Tic Tac Toe' | |
Reason: Who on Earth has a clue? It's an historical thing. | |
And now with automation! | |
""" | |
from itertools import cycle | |
from typing import List, Union, Set, Tuple, Iterator | |
from collections import defaultdict | |
from functools import reduce | |
from operator import mul | |
# External representations of the playing symbols | |
BLANK_SYM: str = '_' | |
O_SYM: str = 'O' | |
X_SYM: str = 'X' | |
# Internal representations of the playing symbols | |
BLANK_VALUE: int = 2 | |
O_VALUE: int = 3 | |
X_VALUE: int = 5 | |
# Translation between systems | |
VAL_TO_SYM: dict = { | |
BLANK_VALUE: BLANK_SYM, | |
O_VALUE: O_SYM, | |
X_VALUE: X_SYM, | |
} | |
SYM_TO_VAL: dict = { | |
BLANK_SYM: BLANK_VALUE, | |
O_SYM: O_VALUE, | |
X_SYM: X_VALUE, | |
} | |
OPPONENT: dict = { | |
O_VALUE: X_VALUE, | |
X_VALUE: O_VALUE, | |
} | |
INTERNAL_TO_EXTERNAL: dict = { | |
0: 7, 1: 8, 2: 9, | |
3: 4, 4: 5, 5: 6, | |
6: 1, 7: 2, 8: 3 | |
} | |
EXTERNAL_TO_INTERNAL: dict = { | |
7: 0, 8: 1, 9: 2, | |
4: 3, 5: 4, 6: 5, | |
1: 6, 2: 7, 3: 8 | |
} | |
VALID_POSITIONS: Set[int] = set(range(1, 10)) | |
# A winning combination exists for three symbols in a row: | |
WINNING_COMBINATIONS: List[Tuple[int, int, int]] = [ | |
(0, 1, 2), (3, 4, 5), (6, 7, 8), | |
(0, 3, 6), (1, 4, 7), (2, 5, 8), | |
(0, 4, 8), (2, 4, 6), | |
] | |
# The product of values in an *almost* winning line… | |
WINNING_PROD = { | |
O_VALUE: O_VALUE * O_VALUE * BLANK_VALUE, | |
X_VALUE: X_VALUE * X_VALUE * BLANK_VALUE, | |
} | |
class BlockedCell(Exception): | |
pass | |
class InvalidMove(Exception): | |
pass | |
class TicTacToe: | |
# define the types of various internal variables | |
_board: List[int] | |
_turn: int | |
_turn_cycle: Iterator[int] | |
_move: int | |
def __init__(self): | |
"""Constructor, allocate the blank board""" | |
# Create an array of cells to hold the grid positions. | |
self._board = [BLANK_VALUE] * len(VALID_POSITIONS) | |
self._turn_cycle = cycle([O_VALUE, X_VALUE]) | |
self._turn = self._next_turn() | |
self._move = 0 | |
def _next_turn(self) -> int: | |
return next(self._turn_cycle) | |
def __str__(self): | |
"""Print the board""" | |
return '\n'.join( | |
' '.join( | |
VAL_TO_SYM[c] | |
for c in self._board[s * 3:(s + 1) * 3] | |
) for s in range(3) | |
) | |
@staticmethod | |
def _ndx_to_cell_(ndx: int) -> int: | |
return EXTERNAL_TO_INTERNAL[ndx] | |
@staticmethod | |
def _cell_to_ndx_(cell: int) -> int: | |
return INTERNAL_TO_EXTERNAL[cell] | |
def player_move(self, target_position: int): | |
""" | |
Attempt to place the player move | |
May raise exceptions: BlockCell, InvalidMove | |
""" | |
if target_position in VALID_POSITIONS: | |
cell = self._ndx_to_cell_(target_position) | |
if self._board[cell] is BLANK_VALUE: | |
self._board[cell] = self._turn | |
else: | |
raise BlockedCell( | |
f'Cannot play at {target_position}, it is already held by {VAL_TO_SYM[self._board[cell]]}') | |
else: | |
raise InvalidMove(f'Invalid move, {target_position} not available') | |
def next_player(self) -> str: | |
self._turn = self._next_turn() | |
return VAL_TO_SYM[self._turn] | |
def find_winner(self) -> Union[int, None]: | |
"""Find a winner, 'O', 'X' or None""" | |
for s in [O_VALUE, X_VALUE]: | |
if any(all(self._board[c] == s for c in combo) for combo in WINNING_COMBINATIONS): | |
return s | |
return None | |
@property | |
def win(self) -> bool: | |
"""Test if the game is won""" | |
return self.find_winner() == self._turn | |
@property | |
def lose(self) -> bool: | |
"""Test if the game is lost""" | |
return self.find_winner() == OPPONENT[self._turn] | |
@property | |
def draw(self) -> bool: | |
"""Test if the game is a draw""" | |
return not (any(c == BLANK_VALUE for c in self._board)) | |
@property | |
def win_draw_lose(self) -> bool: | |
"""Test if the game is still in play""" | |
return self.win or self.lose or self.draw | |
@property | |
def player(self) -> str: | |
return VAL_TO_SYM[self._turn] | |
def _ai_move(self) -> int: | |
"""Identify a move for the computer to make""" | |
winning_lines = defaultdict(set) | |
for line in WINNING_COMBINATIONS: | |
prod = reduce(mul, [self._board[c] for c in line]) | |
winning_lines[prod].add(line) | |
# 1. Let's see if there is a winning line for the current player | |
if winning_lines[WINNING_PROD[self._turn]]: | |
# find the blank in the first winning line. | |
line = winning_lines[WINNING_PROD[self._turn]].pop() | |
return [n for n in line if self._board[n] == BLANK_VALUE][0] | |
# 2. Block: If the opponent has two in a row, the player | |
# must play the third themselves to block the opponent. | |
if winning_lines[WINNING_PROD[OPPONENT[self._turn]]]: | |
# find the blank to play a block. | |
line = winning_lines[WINNING_PROD[OPPONENT[self._turn]]].pop() | |
return [n for n in line if self._board[n] == BLANK_VALUE][0] | |
# 3. Fork & 4. Blocking an opponent's fork, but omitted to give a small opening for a player to win. | |
# 5. Center: A player marks the center. | |
if self._board[4] == BLANK_VALUE: | |
return 4 | |
# 6. Opposite corner: If the opponent is in a corner, the | |
# player plays the opposite corner. | |
for x, y in [(0, 8), (2, 6), (6, 2), (8, 0)]: | |
if self._board[x] == OPPONENT[self._turn] and self._board[y] == BLANK_VALUE: | |
return y | |
# 7. Empty corner: The player plays in a corner square. | |
for x in [0, 2, 6, 8]: | |
if self._board[x] == BLANK_VALUE: | |
return x | |
# 8. Empty side: The player plays in a middle square on any | |
# of the 4 sides. | |
for x in [1, 3, 5, 7]: | |
if self._board[x] == BLANK_VALUE: | |
return x | |
raise InvalidMove(f"Couldn't identify a move to make for AI controlled {self.player}") | |
def ai_move(self) -> str: | |
return str(self._cell_to_ndx_(self._ai_move())) | |
if __name__ == "__main__": | |
while True: | |
game = TicTacToe() | |
print("Let's play Naughts and Crosses!") | |
while not game.win_draw_lose: | |
print('\nCurrent state of game:') | |
print(game) | |
try: | |
if game.player == O_SYM: | |
mv = input(f'Where would you like to play your {game.player}? ') | |
else: | |
mv = game.ai_move() | |
print(f'\nComputer chooses to play {game.player} at {mv}.') | |
position = int(mv) | |
game.player_move(position) | |
if game.win: | |
print(game) | |
print(f'**WIN** We have a WINNER!! Well done {game.player}.') | |
break | |
if game.draw: | |
print(game) | |
print('**DRAW** That was a little pointless in the end.') | |
break | |
game.next_player() | |
except InvalidMove: | |
print(f'Poor choice, Grasshopper, "{mv}" is not and acceptable move: use the numeric keypad layout!') | |
except BlockedCell: | |
print(f'Sorry that spot is already taken.') | |
except ValueError: | |
print(f'Please indicate position as though it were the numeric keypad.') |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment