mirror of
https://github.com/coding-horror/basic-computer-games.git
synced 2025-12-05 20:40:25 -08:00
497 lines
15 KiB
Python
497 lines
15 KiB
Python
"""
|
|
HEXAPAWN
|
|
|
|
A machine learning game, an interpretation of HEXAPAWN game as
|
|
presented in Martin Gardner's "The Unexpected Hanging and Other
|
|
Mathematical Diversions", Chapter Eight: A Matchbox Game-Learning
|
|
Machine.
|
|
|
|
Original version for H-P timeshare system by R.A. Kaapke 5/5/76
|
|
Instructions by Jeff Dalton
|
|
Conversion to MITS BASIC by Steve North
|
|
|
|
|
|
Port to Python by Dave LeCompte
|
|
"""
|
|
|
|
# PORTING NOTES:
|
|
#
|
|
# I printed out the BASIC code and hand-annotated what each little block
|
|
# of code did, which feels amazingly retro.
|
|
#
|
|
# I encourage other porters that have a complex knot of GOTOs and
|
|
# semi-nested subroutines to do hard-copy hacking, it might be a
|
|
# different perspective that helps.
|
|
#
|
|
# A spoiler - the objective of the game is not documented, ostensibly to
|
|
# give the human player a challenge. If a player (human or computer)
|
|
# advances a pawn across the board to the far row, that player wins. If
|
|
# a player has no legal moves (either by being blocked, or all their
|
|
# pieces having been captured), that player loses.
|
|
#
|
|
# The original BASIC had 2 2-dimensional tables stored in DATA at the
|
|
# end of the program. This encoded all 19 different board configurations
|
|
# (Hexapawn is a small game), with reflections in one table, and then in
|
|
# a parallel table, for each of the 19 rows, a list of legal moves was
|
|
# encoded by turning them into 2-digit decimal numbers. As gameplay
|
|
# continued, the AI would overwrite losing moves with 0 in the second
|
|
# array.
|
|
#
|
|
# My port takes this "parallel array" structure and turns that
|
|
# information into a small Python class, BoardLayout. BoardLayout stores
|
|
# the board description and legal moves, but stores the moves as (row,
|
|
# column) 2-tuples, which is easier to read. The logic for checking if a
|
|
# BoardLayout matches the current board, as well as removing losing move
|
|
# have been moved into methods of this class.
|
|
|
|
import random
|
|
from typing import Iterator, List, NamedTuple, Optional, Tuple
|
|
|
|
PAGE_WIDTH = 64
|
|
|
|
HUMAN_PIECE = 1
|
|
EMPTY_SPACE = 0
|
|
COMPUTER_PIECE = -1
|
|
|
|
|
|
class ComputerMove(NamedTuple):
|
|
board_index: int
|
|
move_index: int
|
|
m1: int
|
|
m2: int
|
|
|
|
|
|
wins = 0
|
|
losses = 0
|
|
|
|
|
|
def print_centered(msg: str) -> None:
|
|
spaces = " " * ((PAGE_WIDTH - len(msg)) // 2)
|
|
print(spaces + msg)
|
|
|
|
|
|
def print_header(title: str) -> None:
|
|
print_centered(title)
|
|
print_centered("CREATIVE COMPUTING MORRISTOWN, NEW JERSEY\n\n\n")
|
|
|
|
|
|
def print_instructions() -> None:
|
|
print(
|
|
"""
|
|
THIS PROGRAM PLAYS THE GAME OF HEXAPAWN.
|
|
HEXAPAWN IS PLAYED WITH CHESS PAWNS ON A 3 BY 3 BOARD.
|
|
THE PAWNS ARE MOVED AS IN CHESS - ONE SPACE FORWARD TO
|
|
AN EMPTY SPACE OR ONE SPACE FORWARD AND DIAGONALLY TO
|
|
CAPTURE AN OPPOSING MAN. ON THE BOARD, YOUR PAWNS
|
|
ARE 'O', THE COMPUTER'S PAWNS ARE 'X', AND EMPTY
|
|
SQUARES ARE '.'. TO ENTER A MOVE, TYPE THE NUMBER OF
|
|
THE SQUARE YOU ARE MOVING FROM, FOLLOWED BY THE NUMBER
|
|
OF THE SQUARE YOU WILL MOVE TO. THE NUMBERS MUST BE
|
|
SEPERATED BY A COMMA.
|
|
|
|
THE COMPUTER STARTS A SERIES OF GAMES KNOWING ONLY WHEN
|
|
THE GAME IS WON (A DRAW IS IMPOSSIBLE) AND HOW TO MOVE.
|
|
IT HAS NO STRATEGY AT FIRST AND JUST MOVES RANDOMLY.
|
|
HOWEVER, IT LEARNS FROM EACH GAME. THUS, WINNING BECOMES
|
|
MORE AND MORE DIFFICULT. ALSO, TO HELP OFFSET YOUR
|
|
INITIAL ADVANTAGE, YOU WILL NOT BE TOLD HOW TO WIN THE
|
|
GAME BUT MUST LEARN THIS BY PLAYING.
|
|
|
|
THE NUMBERING OF THE BOARD IS AS FOLLOWS:
|
|
123
|
|
456
|
|
789
|
|
|
|
FOR EXAMPLE, TO MOVE YOUR RIGHTMOST PAWN FORWARD,
|
|
YOU WOULD TYPE 9,6 IN RESPONSE TO THE QUESTION
|
|
'YOUR MOVE ?'. SINCE I'M A GOOD SPORT, YOU'LL ALWAYS
|
|
GO FIRST.
|
|
|
|
"""
|
|
)
|
|
|
|
|
|
def prompt_yes_no(msg: str) -> bool:
|
|
while True:
|
|
print(msg)
|
|
response = input().upper()
|
|
if response[0] == "Y":
|
|
return True
|
|
elif response[0] == "N":
|
|
return False
|
|
|
|
|
|
def reverse_space_name(space_name: int) -> int:
|
|
# reverse a space name in the range 1-9 left to right
|
|
assert 1 <= space_name <= 9
|
|
|
|
reflections = {1: 3, 2: 2, 3: 1, 4: 6, 5: 5, 6: 4, 7: 9, 8: 8, 9: 7}
|
|
return reflections[space_name]
|
|
|
|
|
|
def is_space_in_center_column(space_name: int) -> bool:
|
|
return reverse_space_name(space_name) == space_name
|
|
|
|
|
|
class BoardLayout:
|
|
def __init__(self, cells: List[int], move_list: List[Tuple[int, int]]) -> None:
|
|
self.cells = cells
|
|
self.moves = move_list
|
|
|
|
def _check_match_no_mirror(self, cell_list: List[int]) -> bool:
|
|
return all(
|
|
board_contents == cell_list[space_index]
|
|
for space_index, board_contents in enumerate(self.cells)
|
|
)
|
|
|
|
def _check_match_with_mirror(self, cell_list: List[int]) -> bool:
|
|
for space_index, board_contents in enumerate(self.cells):
|
|
reversed_space_index = reverse_space_name(space_index + 1) - 1
|
|
if board_contents != cell_list[reversed_space_index]:
|
|
return False
|
|
return True
|
|
|
|
def check_match(self, cell_list: List[int]) -> Tuple[bool, Optional[bool]]:
|
|
if self._check_match_with_mirror(cell_list):
|
|
return True, True
|
|
elif self._check_match_no_mirror(cell_list):
|
|
return True, False
|
|
return False, None
|
|
|
|
def get_random_move(
|
|
self, reverse_board: Optional[bool]
|
|
) -> Optional[Tuple[int, int, int]]:
|
|
if not self.moves:
|
|
return None
|
|
move_index = random.randrange(len(self.moves))
|
|
|
|
m1, m2 = self.moves[move_index]
|
|
if reverse_board:
|
|
m1 = reverse_space_name(m1)
|
|
m2 = reverse_space_name(m2)
|
|
|
|
return move_index, m1, m2
|
|
|
|
|
|
boards = [
|
|
BoardLayout([-1, -1, -1, 1, 0, 0, 0, 1, 1], [(2, 4), (2, 5), (3, 6)]),
|
|
BoardLayout([-1, -1, -1, 0, 1, 0, 1, 0, 1], [(1, 4), (1, 5), (3, 6)]),
|
|
BoardLayout([-1, 0, -1, -1, 1, 0, 0, 0, 1], [(1, 5), (3, 5), (3, 6), (4, 7)]),
|
|
BoardLayout([0, -1, -1, 1, -1, 0, 0, 0, 1], [(3, 6), (5, 8), (5, 9)]),
|
|
BoardLayout([-1, 0, -1, 1, 1, 0, 0, 1, 0], [(1, 5), (3, 5), (3, 6)]),
|
|
BoardLayout([-1, -1, 0, 1, 0, 1, 0, 0, 1], [(2, 4), (2, 5), (2, 6)]),
|
|
BoardLayout([0, -1, -1, 0, -1, 1, 1, 0, 0], [(2, 6), (5, 7), (5, 8)]),
|
|
BoardLayout([0, -1, -1, -1, 1, 1, 1, 0, 0], [(2, 6), (3, 5)]),
|
|
BoardLayout([-1, 0, -1, -1, 0, 1, 0, 1, 0], [(4, 7), (4, 8)]),
|
|
BoardLayout([0, -1, -1, 0, 1, 0, 0, 0, 1], [(3, 5), (3, 6)]),
|
|
BoardLayout([0, -1, -1, 0, 1, 0, 1, 0, 0], [(3, 5), (3, 6)]),
|
|
BoardLayout([-1, 0, -1, 1, 0, 0, 0, 0, 1], [(3, 6)]),
|
|
BoardLayout([0, 0, -1, -1, -1, 1, 0, 0, 0], [(4, 7), (5, 8)]),
|
|
BoardLayout([-1, 0, 0, 1, 1, 1, 0, 0, 0], [(1, 5)]),
|
|
BoardLayout([0, -1, 0, -1, 1, 1, 0, 0, 0], [(2, 6), (4, 7)]),
|
|
BoardLayout([-1, 0, 0, -1, -1, 1, 0, 0, 0], [(4, 7), (5, 8)]),
|
|
BoardLayout([0, 0, -1, -1, 1, 0, 0, 0, 0], [(3, 5), (3, 6), (4, 7)]),
|
|
BoardLayout([0, -1, 0, 1, -1, 0, 0, 0, 0], [(2, 8), (5, 8)]),
|
|
BoardLayout([-1, 0, 0, -1, 1, 0, 0, 0, 0], [(1, 5), (4, 7)]),
|
|
]
|
|
|
|
|
|
def get_move(board_index: int, move_index: int) -> Tuple[int, int]:
|
|
assert board_index >= 0 and board_index < len(boards)
|
|
board = boards[board_index]
|
|
|
|
assert move_index >= 0 and move_index < len(board.moves)
|
|
|
|
return board.moves[move_index]
|
|
|
|
|
|
def remove_move(board_index: int, move_index: int) -> None:
|
|
assert board_index >= 0 and board_index < len(boards)
|
|
board = boards[board_index]
|
|
|
|
assert move_index >= 0 and move_index < len(board.moves)
|
|
|
|
del board.moves[move_index]
|
|
|
|
|
|
def init_board() -> List[int]:
|
|
return [COMPUTER_PIECE] * 3 + [EMPTY_SPACE] * 3 + [HUMAN_PIECE] * 3
|
|
|
|
|
|
def print_board(board: List[int]) -> None:
|
|
piece_dict = {COMPUTER_PIECE: "X", EMPTY_SPACE: ".", HUMAN_PIECE: "O"}
|
|
|
|
space = " " * 10
|
|
print()
|
|
for row in range(3):
|
|
line = ""
|
|
for column in range(3):
|
|
line += space
|
|
space_number = row * 3 + column
|
|
space_contents = board[space_number]
|
|
line += piece_dict[space_contents]
|
|
print(line)
|
|
print()
|
|
|
|
|
|
def get_coordinates() -> Tuple[int, int]:
|
|
while True:
|
|
try:
|
|
print("YOUR MOVE?")
|
|
response = input()
|
|
m1, m2 = (int(c) for c in response.split(","))
|
|
return m1, m2
|
|
except ValueError:
|
|
print_illegal()
|
|
|
|
|
|
def print_illegal() -> None:
|
|
print("ILLEGAL MOVE.")
|
|
|
|
|
|
def board_contents(board: List[int], space_number: int) -> int:
|
|
return board[space_number - 1]
|
|
|
|
|
|
def set_board(board: List[int], space_number: int, new_value: int) -> None:
|
|
board[space_number - 1] = new_value
|
|
|
|
|
|
def is_legal_human_move(board: List[int], m1: int, m2: int) -> bool:
|
|
if board_contents(board, m1) != HUMAN_PIECE:
|
|
# Start space doesn't contain player's piece
|
|
return False
|
|
if board_contents(board, m2) == HUMAN_PIECE:
|
|
# Destination space contains player's piece (can't capture your own piece)
|
|
return False
|
|
|
|
is_capture = m2 - m1 != -3
|
|
if is_capture and board_contents(board, m2) != COMPUTER_PIECE:
|
|
# Destination does not contain computer piece
|
|
return False
|
|
|
|
if m2 > m1:
|
|
# can't move backwards
|
|
return False
|
|
|
|
if (not is_capture) and board_contents(board, m2) != EMPTY_SPACE:
|
|
# Destination is not open
|
|
return False
|
|
|
|
return False if m2 - m1 < -4 else m1 != 7 or m2 != 3
|
|
|
|
|
|
def player_piece_on_back_row(board: List[int]) -> bool:
|
|
return any(board_contents(board, space) == HUMAN_PIECE for space in range(1, 4))
|
|
|
|
|
|
def computer_piece_on_front_row(board: List[int]) -> bool:
|
|
return any(board_contents(board, space) == COMPUTER_PIECE for space in range(7, 10))
|
|
|
|
|
|
def all_human_pieces_captured(board: List[int]) -> bool:
|
|
return not list(get_human_spaces(board))
|
|
|
|
|
|
def all_computer_pieces_captured(board: List[int]) -> bool:
|
|
return not list(get_computer_spaces(board))
|
|
|
|
|
|
def human_win(last_computer_move: ComputerMove) -> None:
|
|
print("YOU WIN")
|
|
remove_move(last_computer_move.board_index, last_computer_move.move_index)
|
|
global losses
|
|
losses += 1
|
|
|
|
|
|
def computer_win(has_moves: bool) -> None:
|
|
msg = ("YOU CAN'T MOVE, SO " if not has_moves else "") + "I WIN"
|
|
print(msg)
|
|
global wins
|
|
wins += 1
|
|
|
|
|
|
def show_scores() -> None:
|
|
print(f"I HAVE WON {wins} AND YOU {losses} OUT OF {wins + losses} GAMES.\n")
|
|
|
|
|
|
def human_has_move(board: List[int]) -> bool:
|
|
for i in get_human_spaces(board):
|
|
if board_contents(board, i - 3) == EMPTY_SPACE:
|
|
# can move piece forward
|
|
return True
|
|
elif is_space_in_center_column(i):
|
|
if (board_contents(board, i - 2) == COMPUTER_PIECE) or (
|
|
board_contents(board, i - 4) == COMPUTER_PIECE
|
|
):
|
|
# can capture from center
|
|
return True
|
|
else:
|
|
continue
|
|
elif i < 7:
|
|
assert i in [4, 6]
|
|
if board_contents(board, 2) == COMPUTER_PIECE:
|
|
# can capture computer piece at 2
|
|
return True
|
|
else:
|
|
continue
|
|
elif board_contents(board, 5) == COMPUTER_PIECE:
|
|
assert i in [7, 9]
|
|
# can capture computer piece at 5
|
|
return True
|
|
else:
|
|
continue
|
|
return False
|
|
|
|
|
|
def get_board_spaces() -> Iterator[int]:
|
|
"""generates the space names (1-9)"""
|
|
yield from range(1, 10)
|
|
|
|
|
|
def get_board_spaces_with(board: List[int], val: int) -> Iterator[int]:
|
|
"""generates spaces containing pieces of type val"""
|
|
for i in get_board_spaces():
|
|
if board_contents(board, i) == val:
|
|
yield i
|
|
|
|
|
|
def get_human_spaces(board: List[int]) -> Iterator[int]:
|
|
yield from get_board_spaces_with(board, HUMAN_PIECE)
|
|
|
|
|
|
def get_empty_spaces(board: List[int]) -> Iterator[int]:
|
|
yield from get_board_spaces_with(board, EMPTY_SPACE)
|
|
|
|
|
|
def get_computer_spaces(board: List[int]) -> Iterator[int]:
|
|
yield from get_board_spaces_with(board, COMPUTER_PIECE)
|
|
|
|
|
|
def has_computer_move(board: List[int]) -> bool:
|
|
for i in get_computer_spaces(board):
|
|
if board_contents(board, i + 3) == EMPTY_SPACE:
|
|
# can move forward (down)
|
|
return True
|
|
|
|
if is_space_in_center_column(i):
|
|
# i is in the middle column
|
|
if (board_contents(board, i + 2) == HUMAN_PIECE) or (
|
|
board_contents(board, i + 4) == HUMAN_PIECE
|
|
):
|
|
return True
|
|
elif (
|
|
i > 3
|
|
and board_contents(board, 8) == HUMAN_PIECE
|
|
or i <= 3
|
|
and board_contents(board, 5) == HUMAN_PIECE
|
|
):
|
|
# can capture on 8
|
|
return True
|
|
elif i <= 3 or board_contents(board, 8) == HUMAN_PIECE:
|
|
continue
|
|
return False
|
|
|
|
|
|
def find_board_index_that_matches_board(board: List[int]) -> Tuple[int, Optional[bool]]:
|
|
for board_index, board_layout in enumerate(boards):
|
|
matches, is_reversed = board_layout.check_match(board)
|
|
if matches:
|
|
return board_index, is_reversed
|
|
|
|
# This point should never be reached
|
|
# In future, mypy might be able to check exhaustiveness via assert_never
|
|
raise RuntimeError("ILLEGAL BOARD PATTERN.")
|
|
|
|
|
|
def pick_computer_move(board: List[int]) -> Optional[ComputerMove]:
|
|
if not has_computer_move(board):
|
|
return None
|
|
|
|
board_index, reverse_board = find_board_index_that_matches_board(board)
|
|
|
|
m = boards[board_index].get_random_move(reverse_board)
|
|
|
|
if m is None:
|
|
print("I RESIGN")
|
|
return None
|
|
|
|
move_index, m1, m2 = m
|
|
|
|
return ComputerMove(board_index, move_index, m1, m2)
|
|
|
|
|
|
def get_human_move(board: List[int]) -> Tuple[int, int]:
|
|
while True:
|
|
m1, m2 = get_coordinates()
|
|
|
|
if not is_legal_human_move(board, m1, m2):
|
|
print_illegal()
|
|
else:
|
|
return m1, m2
|
|
|
|
|
|
def apply_move(board: List[int], m1: int, m2: int, piece_value: int) -> None:
|
|
set_board(board, m1, EMPTY_SPACE)
|
|
set_board(board, m2, piece_value)
|
|
|
|
|
|
def play_game() -> None:
|
|
last_computer_move = None
|
|
|
|
board = init_board()
|
|
|
|
while True:
|
|
print_board(board)
|
|
|
|
m1, m2 = get_human_move(board)
|
|
|
|
apply_move(board, m1, m2, HUMAN_PIECE)
|
|
|
|
print_board(board)
|
|
|
|
if player_piece_on_back_row(board) or all_computer_pieces_captured(board):
|
|
assert last_computer_move is not None
|
|
human_win(last_computer_move)
|
|
return
|
|
|
|
computer_move = pick_computer_move(board)
|
|
if computer_move is None:
|
|
assert last_computer_move is not None
|
|
human_win(last_computer_move)
|
|
return
|
|
|
|
last_computer_move = computer_move
|
|
|
|
m1, m2 = last_computer_move.m1, last_computer_move.m2
|
|
|
|
print(f"I MOVE FROM {m1} TO {m2}")
|
|
apply_move(board, m1, m2, COMPUTER_PIECE)
|
|
|
|
print_board(board)
|
|
|
|
if computer_piece_on_front_row(board):
|
|
computer_win(True)
|
|
return
|
|
elif (not human_has_move(board)) or (all_human_pieces_captured(board)):
|
|
computer_win(False)
|
|
return
|
|
|
|
|
|
def main() -> None:
|
|
print_header("HEXAPAWN")
|
|
if prompt_yes_no("INSTRUCTIONS (Y-N)?"):
|
|
print_instructions()
|
|
|
|
global wins, losses
|
|
wins = 0
|
|
losses = 0
|
|
|
|
while True:
|
|
play_game()
|
|
show_scores()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|