Skip to content

Commit d6a175c

Browse files
AdityaDaflapurkarnorvig
authored andcommitted
Backgammon implementation (#783)
* Create model classes for backgammon * Add game functions to model * Implement expectiminimax function * Correct logic in some functions * Correct expectiminimax logic * Refactor code and add docstrings * Remove print statements
1 parent d1f162b commit d6a175c

File tree

1 file changed

+206
-2
lines changed

1 file changed

+206
-2
lines changed

games.py

Lines changed: 206 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
from collections import namedtuple
44
import random
5-
6-
from utils import argmax
5+
import itertools
6+
import copy
7+
from utils import argmax, vector_add
78

89
infinity = float('inf')
910
GameState = namedtuple('GameState', 'to_move, utility, board, moves')
@@ -40,6 +41,47 @@ def min_value(state):
4041

4142
# ______________________________________________________________________________
4243

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+
4385

4486
def alphabeta_search(state, game):
4587
"""Search game to determine best action; use alpha-beta pruning.
@@ -155,6 +197,9 @@ def random_player(game, state):
155197
def alphabeta_player(game, state):
156198
return alphabeta_search(state, game)
157199

200+
def expectiminimax_player(game, state):
201+
return expectiminimax(state, game)
202+
158203

159204
# ______________________________________________________________________________
160205
# Some Sample Games
@@ -342,3 +387,162 @@ def __init__(self, h=7, v=6, k=4):
342387
def actions(self, state):
343388
return [(x, y) for (x, y) in state.moves
344389
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

Comments
 (0)