""" AWARI An ancient African game (see also Kalah, Mancala). Ported by Dave LeCompte """ """ PORTING NOTES This game started out as 70 lines of BASIC, and I have ported it before. I find it somewhat amazing how efficient (densely packed) the original code is. Of course, the original code has fairly cryptic variable names (as was forced by BASIC's limitation on long (2+ character) variable names). I have done my best here to interpret what each variable is doing in context, and rename them appropriately. I have endeavored to leave the logic of the code in place, as it's interesting to see a 2-ply game tree evaluation written in BASIC, along with what a reader in 2021 would call "machine learning". As each game is played, the move history is stored as base-6 digits stored losing_book[game_number]. If the human player wins or draws, the computer increments game_number, effectively "recording" that loss to be referred to later. As the computer evaluates moves, it checks the potential game state against these losing game records, and if the potential move matches with the losing game (up to the current number of moves), that move is evaluated at a two point penalty. Compare this, for example with MENACE, a mechanical device for "learning" tic-tac-toe: https://en.wikipedia.org/wiki/Matchbox_Educable_Noughts_and_Crosses_Engine The base-6 representation allows game history to be VERY efficiently represented. I considered whether to rewrite this representation to be easier to read, but I elected to TRY to document it, instead. Another place where I have made a difficult decision between accuracy and correctness is inside the "wrapping" code where it considers "while human_move_end > 13". The original BASIC code reads: 830 IF L>13 THEN L=L-14:R=1:GOTO 830 I suspect that the intention is not to assign 1 to R, but to increment R. I discuss this more in a porting note comment next to the translated code. If you wish to play a more accurate version of the game as written in the book, you can convert the increment back to an assignment. I continue to be impressed with this jewel of a game; as soon as I had the AI playing against me, it was beating me. I've been able to score a few wins against the computer, but even at its 2-ply lookahead, it beats me nearly always. I would like to become better at this game to explore the effectiveness of the "losing book" machine learning. EXERCISES FOR THE READER One could go many directions with this game: - change the initial number of stones in each pit - change the number of pits - only allow capturing if you end on your side of the board - don't allow capturing at all - don't drop a stone into the enemy "home" - go clockwise, instead - allow the player to choose to go clockwise or counterclockwise - instead of a maximum of two moves, allow each move that ends on the "home" to be followed by a free move. - increase the AI lookahead - make the scoring heuristic a little more nuanced - store history to a file on disk (or in the cloud!) to allow the AI to learn over more than a single session """ game_number = 0 move_count = 0 losing_book = [] n = 0 MAX_HISTORY = 9 LOSING_BOOK_SIZE = 50 def print_with_tab(space_count, msg): if space_count > 0: spaces = " " * space_count else: spaces = "" print(spaces + msg) def draw_pit(line, board, pit_index): val = board[pit_index] line = line + " " if val < 10: line = line + " " line = line + str(val) + " " return line def draw_board(board): print() # Draw the top (computer) pits line = " " for i in range(12, 6, -1): line = draw_pit(line, board, i) print(line) # Draw the side (home) pits line = draw_pit("", board, 13) line += " " * 24 line = draw_pit(line, board, 6) print(line) # Draw the bottom (player) pits line = " " for i in range(0, 6): line = draw_pit(line, board, i) print(line) print() print() def play_game(board): # Place the beginning stones for i in range(0, 13): board[i] = 3 # Empty the home pits board[6] = 0 board[13] = 0 global move_count move_count = 0 # clear the history record for this game losing_book[game_number] = 0 while True: draw_board(board) print("YOUR MOVE") landing_spot, is_still_going, home = player_move(board) if not is_still_going: break if landing_spot == home: landing_spot, is_still_going, home = player_move_again(board) if not is_still_going: break print("MY MOVE") landing_spot, is_still_going, home, msg = computer_move("", board) if not is_still_going: print(msg) break if landing_spot == home: landing_spot, is_still_going, home, msg = computer_move(msg + " , ", board) if not is_still_going: print(msg) break print(msg) game_over(board) def computer_move(msg, board): # This function does a two-ply lookahead evaluation; one computer # move plus one human move. # # To do this, it makes a copy (temp_board) of the board, plays # each possible computer move and then uses math to work out what # the scoring heuristic is for each possible human move. # # Additionally, if it detects that a potential move puts it on a # series of moves that it has recorded in its "losing book", it # penalizes that move by two stones. best_quality = -99 # Make a copy of the board, so that we can experiment. We'll put # everything back, later. temp_board = board[:] # For each legal computer move 7-12 for computer_move in range(7, 13): if board[computer_move] == 0: continue do_move(computer_move, 13, board) # try the move (1 move lookahead) best_player_move_quality = 0 # for all legal human moves 0-5 (responses to computer move computer_move) for human_move_start in range(0, 6): if board[human_move_start] == 0: continue human_move_end = board[human_move_start] + human_move_start this_player_move_quality = 0 # If this move goes around the board, wrap backwards. # # PORTING NOTE: The careful reader will note that I am # incrementing this_player_move_quality for each wrap, # while the original code only set it equal to 1. # # I expect this was a typo or oversight, but I also # recognize that you'd have to go around the board more # than once for this to be a difference, and even so, it # would be a very small difference; there are only 36 # stones in the game, and going around the board twice # requires 24 stones. while human_move_end > 13: human_move_end = human_move_end - 14 this_player_move_quality += 1 if ( (board[human_move_end] == 0) and (human_move_end != 6) and (human_move_end != 13) ): # score the capture this_player_move_quality += board[12 - human_move_end] if this_player_move_quality > best_player_move_quality: best_player_move_quality = this_player_move_quality # This is a zero sum game, so the better the human player's # move is, the worse it is for the computer player. computer_move_quality = board[13] - board[6] - best_player_move_quality if move_count < MAX_HISTORY: move_digit = computer_move if move_digit > 6: move_digit = move_digit - 7 # Calculate the base-6 history representation of the game # with this move. If that history is in our "losing book", # penalize that move. for prev_game_number in range(game_number): if losing_book[game_number] * 6 + move_digit == int( losing_book[prev_game_number] / 6 ^ (7 - move_count) + 0.1 ): computer_move_quality -= 2 # Copy back from temporary board for i in range(14): board[i] = temp_board[i] if computer_move_quality >= best_quality: best_move = computer_move best_quality = computer_move_quality selected_move = best_move move_str = chr(42 + selected_move) if msg: msg += ", " + move_str else: msg = move_str move_number, is_still_going, home = execute_move(selected_move, 13, board) return move_number, is_still_going, home, msg def game_over(board): print() print("GAME OVER") pit_difference = board[6] - board[13] if pit_difference < 0: print(f"I WIN BY {-pit_difference} POINTS") else: global n n = n + 1 if pit_difference == 0: print("DRAWN GAME") else: print(f"YOU WIN BY {pit_difference} POINTS") def do_capture(m, home, board): board[home] += board[12 - m] + 1 board[m] = 0 board[12 - m] = 0 def do_move(m, home, board): move_stones = board[m] board[m] = 0 for stones in range(move_stones, 0, -1): m = m + 1 if m > 13: m = m - 14 board[m] += 1 if board[m] == 1: # capture if (m != 6) and (m != 13) and (board[12 - m] != 0): do_capture(m, home, board) return m def player_has_stones(board): for i in range(6): if board[i] > 0: return True return False def computer_has_stones(board): for i in range(7, 13): if board[i] > 0: return True return False def execute_move(move, home, board): move_digit = move last_location = do_move(move, home, board) if move_digit > 6: move_digit = move_digit - 7 global move_count move_count += 1 if move_count < MAX_HISTORY: # The computer keeps a chain of moves in losing_book by # storing a sequence of moves as digits in a base-6 number. # # game_number represents the current game, # losing_book[game_number] records the history of the ongoing # game. When the computer evaluates moves, it tries to avoid # moves that will lead it into paths that have led to previous # losses. losing_book[game_number] = losing_book[game_number] * 6 + move_digit if player_has_stones(board) and computer_has_stones(board): is_still_going = True else: is_still_going = False return last_location, is_still_going, home def player_move_again(board): print("AGAIN") return player_move(board) def player_move(board): while True: print("SELECT MOVE 1-6") m = int(input()) - 1 if m > 5 or m < 0 or board[m] == 0: print("ILLEGAL MOVE") continue break ending_spot, is_still_going, home = execute_move(m, 6, board) draw_board(board) return ending_spot, is_still_going, home def main(): print_with_tab(34, "AWARI") print_with_tab(15, "CREATIVE COMPUTING MORRISTOWN, NEW JERSEY") print() print() board = [0] * 14 # clear the board representation global losing_book losing_book = [0] * LOSING_BOOK_SIZE # clear the "machine learning" state while True: play_game(board) if __name__ == "__main__": main()