Files
basic-computer-games/46_Hexapawn/python/hexapawn.py
2024-08-19 03:46:27 +03:00

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