|
2 | 2 |
|
3 | 3 | from collections import namedtuple
|
4 | 4 | import random
|
5 |
| - |
6 |
| -from utils import argmax |
| 5 | +import itertools |
| 6 | +import copy |
| 7 | +from utils import argmax, vector_add |
7 | 8 |
|
8 | 9 | infinity = float('inf')
|
9 | 10 | GameState = namedtuple('GameState', 'to_move, utility, board, moves')
|
@@ -40,6 +41,47 @@ def min_value(state):
|
40 | 41 |
|
41 | 42 | # ______________________________________________________________________________
|
42 | 43 |
|
| 44 | +def expectiminimax(state, game): |
| 45 | + """Returns the best move for a player after dice are thrown. The game tree |
| 46 | + includes chance nodes along with min and max nodes. [Figure 5.11]""" |
| 47 | + player = game.to_move(state) |
| 48 | + |
| 49 | + def max_value(state): |
| 50 | + if game.terminal_test(state): |
| 51 | + return game.utility(state, player) |
| 52 | + v = -infinity |
| 53 | + for a in game.actions(state): |
| 54 | + v = max(v, chance_node(state, a)) |
| 55 | + return v |
| 56 | + |
| 57 | + def min_value(state): |
| 58 | + if game.terminal_test(state): |
| 59 | + return game.utility(state, player) |
| 60 | + v = infinity |
| 61 | + for a in game.actions(state): |
| 62 | + v = min(v, chance_node(state, a)) |
| 63 | + return v |
| 64 | + |
| 65 | + def chance_node(state, action): |
| 66 | + res_state = game.result(state, action) |
| 67 | + sum_chances = 0 |
| 68 | + num_chances = 21 |
| 69 | + dice_rolls = list(itertools.combinations_with_replacement([1, 2, 3, 4, 5, 6], 2)) |
| 70 | + if res_state.to_move == 'W': |
| 71 | + for val in dice_rolls: |
| 72 | + game.dice_roll = (-val[0], -val[1]) |
| 73 | + sum_chances += max_value(res_state) * (1/36 if val[0] == val[1] else 1/18) |
| 74 | + elif res_state.to_move == 'B': |
| 75 | + for val in dice_rolls: |
| 76 | + game.dice_roll = val |
| 77 | + sum_chances += min_value(res_state) * (1/36 if val[0] == val[1] else 1/18) |
| 78 | + |
| 79 | + return sum_chances / num_chances |
| 80 | + |
| 81 | + # Body of expectiminimax: |
| 82 | + return argmax(game.actions(state), |
| 83 | + key=lambda a: chance_node(state, a)) |
| 84 | + |
43 | 85 |
|
44 | 86 | def alphabeta_search(state, game):
|
45 | 87 | """Search game to determine best action; use alpha-beta pruning.
|
@@ -155,6 +197,9 @@ def random_player(game, state):
|
155 | 197 | def alphabeta_player(game, state):
|
156 | 198 | return alphabeta_search(state, game)
|
157 | 199 |
|
| 200 | +def expectiminimax_player(game, state): |
| 201 | + return expectiminimax(state, game) |
| 202 | + |
158 | 203 |
|
159 | 204 | # ______________________________________________________________________________
|
160 | 205 | # Some Sample Games
|
@@ -342,3 +387,162 @@ def __init__(self, h=7, v=6, k=4):
|
342 | 387 | def actions(self, state):
|
343 | 388 | return [(x, y) for (x, y) in state.moves
|
344 | 389 | if y == 1 or (x, y - 1) in state.board]
|
| 390 | + |
| 391 | + |
| 392 | +class Backgammon(Game): |
| 393 | + """A two player game where the goal of each player is to move all the |
| 394 | + checkers off the board. The moves for each state are determined by |
| 395 | + rolling a pair of dice.""" |
| 396 | + |
| 397 | + def __init__(self): |
| 398 | + self.dice_roll = (-random.randint(1, 6), -random.randint(1, 6)) |
| 399 | + board = Board() |
| 400 | + self.initial = GameState(to_move='W', |
| 401 | + utility=0, board=board, moves=self.get_all_moves(board, 'W')) |
| 402 | + |
| 403 | + def actions(self, state): |
| 404 | + """Returns a list of legal moves for a state.""" |
| 405 | + player = state.to_move |
| 406 | + moves = state.moves |
| 407 | + legal_moves = [] |
| 408 | + for move in moves: |
| 409 | + board = copy.deepcopy(state.board) |
| 410 | + if board.is_legal_move(move, self.dice_roll, player): |
| 411 | + legal_moves.append(move) |
| 412 | + return legal_moves |
| 413 | + |
| 414 | + def result(self, state, move): |
| 415 | + board = copy.deepcopy(state.board) |
| 416 | + player = state.to_move |
| 417 | + board.move_checker(move[0], self.dice_roll[0], player) |
| 418 | + board.move_checker(move[1], self.dice_roll[1], player) |
| 419 | + to_move = ('W' if player == 'B' else 'B') |
| 420 | + return GameState(to_move=to_move, |
| 421 | + utility=self.compute_utility(board, move, to_move), |
| 422 | + board=board, |
| 423 | + moves=self.get_all_moves(board, to_move)) |
| 424 | + |
| 425 | + |
| 426 | + def utility(self, state, player): |
| 427 | + """Return the value to player; 1 for win, -1 for loss, 0 otherwise.""" |
| 428 | + return state.utility if player == 'W' else -state.utility |
| 429 | + |
| 430 | + def terminal_test(self, state): |
| 431 | + """A state is terminal if one player wins.""" |
| 432 | + return state.utility != 0 |
| 433 | + |
| 434 | + def get_all_moves(self, board, player): |
| 435 | + """All possible moves for a player i.e. all possible ways of |
| 436 | + choosing two checkers of a player from the board for a move |
| 437 | + at a given state.""" |
| 438 | + all_points = board.points |
| 439 | + taken_points = [index for index, point in enumerate(all_points) |
| 440 | + if point.checkers[player] > 0] |
| 441 | + moves = list(itertools.permutations(taken_points, 2)) |
| 442 | + moves = moves + [(index, index) for index, point in enumerate(all_points) |
| 443 | + if point.checkers[player] >= 2] |
| 444 | + return moves |
| 445 | + |
| 446 | + def display(self, state): |
| 447 | + """Display state of the game.""" |
| 448 | + board = state.board |
| 449 | + player = state.to_move |
| 450 | + for index, point in enumerate(board.points): |
| 451 | + if point.checkers['W'] != 0 or point.checkers['B'] != 0: |
| 452 | + print("Point : ", index, " W : ", point.checkers['W'], " B : ", point.checkers['B']) |
| 453 | + print("player : ", player) |
| 454 | + |
| 455 | + |
| 456 | + def compute_utility(self, board, move, player): |
| 457 | + """If 'W' wins with this move, return 1; if 'B' wins return -1; else return 0.""" |
| 458 | + count = 0 |
| 459 | + for idx in range(0, 24): |
| 460 | + count = count + board.points[idx].checkers[player] |
| 461 | + if player == 'W' and count == 0: |
| 462 | + return 1 |
| 463 | + if player == 'B' and count == 0: |
| 464 | + return -1 |
| 465 | + return 0 |
| 466 | + |
| 467 | + |
| 468 | +class Board: |
| 469 | + """The board consists of 24 points. Each player('W' and 'B') initially |
| 470 | + has 15 checkers on board. Player 'W' moves from point 23 to point 0 |
| 471 | + and player 'B' moves from point 0 to 23. Points 0-7 are |
| 472 | + home for player W and points 17-24 are home for B.""" |
| 473 | + |
| 474 | + def __init__(self): |
| 475 | + """Initial state of the game""" |
| 476 | + # TODO : Add bar to Board class where a blot is placed when it is hit. |
| 477 | + self.points = [Point() for index in range(24)] |
| 478 | + self.points[0].checkers['B'] = self.points[23].checkers['W'] = 2 |
| 479 | + self.points[5].checkers['W'] = self.points[18].checkers['B'] = 5 |
| 480 | + self.points[7].checkers['W'] = self.points[16].checkers['B'] = 3 |
| 481 | + self.points[11].checkers['B'] = self.points[12].checkers['W'] = 5 |
| 482 | + self.allow_bear_off = {'W': False, 'B': False} |
| 483 | + |
| 484 | + def checkers_at_home(self, player): |
| 485 | + """Returns the no. of checkers at home for a player.""" |
| 486 | + sum_range = range(0, 7) if player == 'W' else range(17, 24) |
| 487 | + count = 0 |
| 488 | + for idx in sum_range: |
| 489 | + count = count + self.points[idx].checkers[player] |
| 490 | + return count |
| 491 | + |
| 492 | + def is_legal_move(self, start, steps, player): |
| 493 | + """Move is a tuple which contains starting points of checkers to be |
| 494 | + moved during a player's turn. An on-board move is legal if both the destinations |
| 495 | + are open. A bear-off move is the one where a checker is moved off-board. |
| 496 | + It is legal only after a player has moved all his checkers to his home.""" |
| 497 | + dest1, dest2 = vector_add(start, steps) |
| 498 | + dest_range = range(0, 24) |
| 499 | + move1_legal = move2_legal = False |
| 500 | + if dest1 in dest_range: |
| 501 | + if self.points[dest1].is_open_for(player): |
| 502 | + self.move_checker(start[0], steps[0], player) |
| 503 | + move1_legal = True |
| 504 | + else: |
| 505 | + if self.allow_bear_off[player]: |
| 506 | + self.move_checker(start[0], steps[0], player) |
| 507 | + move1_legal = True |
| 508 | + if not move1_legal: |
| 509 | + return False |
| 510 | + if dest2 in dest_range: |
| 511 | + if self.points[dest2].is_open_for(player): |
| 512 | + move2_legal = True |
| 513 | + else: |
| 514 | + if self.allow_bear_off[player]: |
| 515 | + move2_legal = True |
| 516 | + return move1_legal and move2_legal |
| 517 | + |
| 518 | + def move_checker(self, start, steps, player): |
| 519 | + """Moves a checker from starting point by a given number of steps""" |
| 520 | + dest = start + steps |
| 521 | + dest_range = range(0, 24) |
| 522 | + self.points[start].remove_checker(player) |
| 523 | + if dest in dest_range: |
| 524 | + self.points[dest].add_checker(player) |
| 525 | + if self.checkers_at_home(player) == 15: |
| 526 | + self.allow_bear_off[player] = True |
| 527 | + |
| 528 | +class Point: |
| 529 | + """A point is one of the 24 triangles on the board where |
| 530 | + the players' checkers are placed.""" |
| 531 | + |
| 532 | + def __init__(self): |
| 533 | + self.checkers = {'W':0, 'B':0} |
| 534 | + |
| 535 | + def is_open_for(self, player): |
| 536 | + """A point is open for a player if the no. of opponent's |
| 537 | + checkers already present on it is 0 or 1. A player can |
| 538 | + move a checker to a point only if it is open.""" |
| 539 | + opponent = 'B' if player == 'W' else 'W' |
| 540 | + return self.checkers[opponent] <= 1 |
| 541 | + |
| 542 | + def add_checker(self, player): |
| 543 | + """Place a player's checker on a point.""" |
| 544 | + self.checkers[player] += 1 |
| 545 | + |
| 546 | + def remove_checker(self, player): |
| 547 | + """Remove a player's checker from a point.""" |
| 548 | + self.checkers[player] -= 1 |
0 commit comments