isolation.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. """
  2. This file contains the `Board` class, which implements the rules for the
  3. game Isolation as described in lecture, modified so that the players move
  4. like knights in chess rather than queens.
  5. You MAY use and modify this class, however ALL function signatures must
  6. remain compatible with the defaults provided, and none of your changes will
  7. be available to project reviewers.
  8. """
  9. import random
  10. import timeit
  11. from copy import copy
  12. TIME_LIMIT_MILLIS = 150
  13. class Board(object):
  14. """Implement a model for the game Isolation assuming each player moves like
  15. a knight in chess.
  16. Parameters
  17. ----------
  18. player_1 : object
  19. An object with a get_move() function. This is the only function
  20. directly called by the Board class for each player.
  21. player_2 : object
  22. An object with a get_move() function. This is the only function
  23. directly called by the Board class for each player.
  24. width : int (optional)
  25. The number of columns that the board should have.
  26. height : int (optional)
  27. The number of rows that the board should have.
  28. """
  29. BLANK = 0
  30. NOT_MOVED = None
  31. def __init__(self, player_1, player_2, width=7, height=7):
  32. self.width = width
  33. self.height = height
  34. self.move_count = 0
  35. self._player_1 = player_1
  36. self._player_2 = player_2
  37. self._active_player = player_1
  38. self._inactive_player = player_2
  39. # The last 3 entries of the board state includes initiative (0 for
  40. # player 1, 1 for player 2) player 2 last move, and player 1 last move
  41. self._board_state = [Board.BLANK] * (width * height + 3)
  42. self._board_state[-1] = Board.NOT_MOVED
  43. self._board_state[-2] = Board.NOT_MOVED
  44. def hash(self):
  45. return str(self._board_state).__hash__()
  46. @property
  47. def active_player(self):
  48. """The object registered as the player holding initiative in the
  49. current game state.
  50. """
  51. return self._active_player
  52. @property
  53. def inactive_player(self):
  54. """The object registered as the player in waiting for the current
  55. game state.
  56. """
  57. return self._inactive_player
  58. def get_opponent(self, player):
  59. """Return the opponent of the supplied player.
  60. Parameters
  61. ----------
  62. player : object
  63. An object registered as a player in the current game. Raises an
  64. error if the supplied object is not registered as a player in
  65. this game.
  66. Returns
  67. -------
  68. object
  69. The opponent of the input player object.
  70. """
  71. if player == self._active_player:
  72. return self._inactive_player
  73. elif player == self._inactive_player:
  74. return self._active_player
  75. raise RuntimeError("`player` must be an object registered as a player in the current game.")
  76. def copy(self):
  77. """ Return a deep copy of the current board. """
  78. new_board = Board(self._player_1, self._player_2, width=self.width, height=self.height)
  79. new_board.move_count = self.move_count
  80. new_board._active_player = self._active_player
  81. new_board._inactive_player = self._inactive_player
  82. new_board._board_state = copy(self._board_state)
  83. return new_board
  84. def forecast_move(self, move):
  85. """Return a deep copy of the current game with an input move applied to
  86. advance the game one ply.
  87. Parameters
  88. ----------
  89. move : (int, int)
  90. A coordinate pair (row, column) indicating the next position for
  91. the active player on the board.
  92. Returns
  93. -------
  94. isolation.Board
  95. A deep copy of the board with the input move applied.
  96. """
  97. new_board = self.copy()
  98. new_board.apply_move(move)
  99. return new_board
  100. def move_is_legal(self, move):
  101. """Test whether a move is legal in the current game state.
  102. Parameters
  103. ----------
  104. move : (int, int)
  105. A coordinate pair (row, column) indicating the next position for
  106. the active player on the board.
  107. Returns
  108. -------
  109. bool
  110. Returns True if the move is legal, False otherwise
  111. """
  112. idx = move[0] + move[1] * self.height
  113. return (0 <= move[0] < self.height and 0 <= move[1] < self.width and
  114. self._board_state[idx] == Board.BLANK)
  115. def get_blank_spaces(self):
  116. """Return a list of the locations that are still available on the board.
  117. """
  118. return [(i, j) for j in range(self.width) for i in range(self.height)
  119. if self._board_state[i + j * self.height] == Board.BLANK]
  120. def get_player_location(self, player):
  121. """Find the current location of the specified player on the board.
  122. Parameters
  123. ----------
  124. player : object
  125. An object registered as a player in the current game.
  126. Returns
  127. -------
  128. (int, int) or None
  129. The coordinate pair (row, column) of the input player, or None
  130. if the player has not moved.
  131. """
  132. if player == self._player_1:
  133. if self._board_state[-1] == Board.NOT_MOVED:
  134. return Board.NOT_MOVED
  135. idx = self._board_state[-1]
  136. elif player == self._player_2:
  137. if self._board_state[-2] == Board.NOT_MOVED:
  138. return Board.NOT_MOVED
  139. idx = self._board_state[-2]
  140. else:
  141. raise RuntimeError(
  142. "Invalid player in get_player_location: {}".format(player))
  143. w = idx // self.height
  144. h = idx % self.height
  145. return (h, w)
  146. def get_legal_moves(self, player=None):
  147. """Return the list of all legal moves for the specified player.
  148. Parameters
  149. ----------
  150. player : object (optional)
  151. An object registered as a player in the current game. If None,
  152. return the legal moves for the active player on the board.
  153. Returns
  154. -------
  155. list<(int, int)>
  156. The list of coordinate pairs (row, column) of all legal moves
  157. for the player constrained by the current game state.
  158. """
  159. if player is None:
  160. player = self.active_player
  161. return self.get_moves(self.get_player_location(player))
  162. def apply_move(self, move):
  163. """Move the active player to a specified location.
  164. Parameters
  165. ----------
  166. move : (int, int)
  167. A coordinate pair (row, column) indicating the next position for
  168. the active player on the board.
  169. """
  170. idx = move[0] + move[1] * self.height
  171. last_move_idx = int(self.active_player == self._player_2) + 1
  172. self._board_state[-last_move_idx] = idx
  173. self._board_state[idx] = 1
  174. self._board_state[-3] ^= 1
  175. self._active_player, self._inactive_player = self._inactive_player, self._active_player
  176. self.move_count += 1
  177. def is_winner(self, player):
  178. """ Test whether the specified player has won the game. """
  179. return player == self._inactive_player and not self.get_legal_moves(self._active_player)
  180. def is_loser(self, player):
  181. """ Test whether the specified player has lost the game. """
  182. return player == self._active_player and not self.get_legal_moves(self._active_player)
  183. def utility(self, player):
  184. """Returns the utility of the current game state from the perspective
  185. of the specified player.
  186. / +infinity, "player" wins
  187. utility = | -infinity, "player" loses
  188. \ 0, otherwise
  189. Parameters
  190. ----------
  191. player : object (optional)
  192. An object registered as a player in the current game. If None,
  193. return the utility for the active player on the board.
  194. Returns
  195. ----------
  196. float
  197. The utility value of the current game state for the specified
  198. player. The game has a utility of +inf if the player has won,
  199. a value of -inf if the player has lost, and a value of 0
  200. otherwise.
  201. """
  202. if not self.get_legal_moves(self._active_player):
  203. if player == self._inactive_player:
  204. return float("inf")
  205. if player == self._active_player:
  206. return float("-inf")
  207. return 0.
  208. def get_moves(self, loc):
  209. """Generate the list of possible moves for an L-shaped motion (like a
  210. knight in chess).
  211. """
  212. if loc == Board.NOT_MOVED:
  213. return self.get_blank_spaces()
  214. r, c = loc
  215. directions = [(-2, -1), (-2, 1), (-1, -2), (-1, 2),
  216. (1, -2), (1, 2), (2, -1), (2, 1)]
  217. valid_moves = [(r + dr, c + dc) for dr, dc in directions
  218. if self.move_is_legal((r + dr, c + dc))]
  219. random.shuffle(valid_moves)
  220. return valid_moves
  221. def print_board(self):
  222. """DEPRECATED - use Board.to_string()"""
  223. return self.to_string()
  224. def to_string(self, symbols=['1', '2']):
  225. """Generate a string representation of the current game state, marking
  226. the location of each player and indicating which cells have been
  227. blocked, and which remain open.
  228. """
  229. p1_loc = self._board_state[-1]
  230. p2_loc = self._board_state[-2]
  231. col_margin = len(str(self.height - 1)) + 1
  232. prefix = "{:<" + "{}".format(col_margin) + "}"
  233. offset = " " * (col_margin + 3)
  234. out = offset + ' '.join(map(str, range(self.width))) + '\n\r'
  235. for i in range(self.height):
  236. out += prefix.format(i) + ' | '
  237. for j in range(self.width):
  238. idx = i + j * self.height
  239. if not self._board_state[idx]:
  240. out += ' '
  241. elif p1_loc == idx:
  242. out += symbols[0]
  243. elif p2_loc == idx:
  244. out += symbols[1]
  245. else:
  246. out += '-'
  247. out += ' | '
  248. out += '\n\r'
  249. return out
  250. def play(self, time_limit=TIME_LIMIT_MILLIS):
  251. """Execute a match between the players by alternately soliciting them
  252. to select a move and applying it in the game.
  253. Parameters
  254. ----------
  255. time_limit : numeric (optional)
  256. The maximum number of milliseconds to allow before timeout
  257. during each turn.
  258. Returns
  259. ----------
  260. (player, list<[(int, int),]>, str)
  261. Return multiple including the winning player, the complete game
  262. move history, and a string indicating the reason for losing
  263. (e.g., timeout or invalid move).
  264. """
  265. move_history = []
  266. time_millis = lambda: 1000 * timeit.default_timer()
  267. while True:
  268. legal_player_moves = self.get_legal_moves()
  269. game_copy = self.copy()
  270. move_start = time_millis()
  271. time_left = lambda : time_limit - (time_millis() - move_start)
  272. curr_move = self._active_player.get_move(
  273. game_copy, legal_player_moves, time_left)
  274. move_end = time_left()
  275. if curr_move is None:
  276. curr_move = Board.NOT_MOVED
  277. if move_end < 0:
  278. return self._inactive_player, move_history, "timeout"
  279. if curr_move not in legal_player_moves:
  280. if len(legal_player_moves) > 0:
  281. return self._inactive_player, move_history, "forfeit"
  282. return self._inactive_player, move_history, "illegal move"
  283. move_history.append(list(curr_move))
  284. self.apply_move(curr_move)