def test_manual_game(): """ Manual game test """ # Initial, empty game state state = State(tileset = NewTileSet, manual_wordcheck = True, drawtiles = True) print(u"Manual game") print(u"After initial draw, bag contains {0} tiles".format(state.bag().num_tiles())) print(u"Bag contents are:\n{0}".format(state.bag().contents())) print(u"Rack 0 is {0}".format(state.rack(0))) print(u"Rack 1 is {0}".format(state.rack(1))) # Set player names for ix in range(2): state.set_player_name(ix, "Player " + ("A", "B")[ix]) # print(state.__str__()) # This works in Python 2 and 3 state.player_rack().set_tiles(u"stuðinn") test_move(state, u"H4 stuði") state.player_rack().set_tiles(u"dettsfj") test_move(state, u"5E detts") test_exchange(state, 3) state.player_rack().set_tiles(u"dýsturi") test_move(state, u"I3 dýs") state.player_rack().set_tiles(u"?xalmen") test_move(state, u"6E ?óx") # The question mark indicates a blank tile for the subsequent cover state.player_rack().set_tiles(u"eiðarps") test_move(state, u"9F eipar") test_challenge(state) test_response(state) state.player_rack().set_tiles(u"sóbetis") test_move(state, u"J3 ós") test_move(state, u"9F eiðar") test_challenge(state) test_response(state) # Tally the tiles left and calculate the final score state.finalize_score() p0, p1 = state.scores() print(u"Manual game over, final score {3} {0} : {4} {1} after {2} moves".format(p0, p1, state.num_moves(), state.player_name(0), state.player_name(1)))
def test_manual_game(): """ Manual game test """ # Initial, empty game state state = State(tileset=NewTileSet, manual_wordcheck=True, drawtiles=True) print(u"Manual game") print(u"After initial draw, bag contains {0} tiles".format( state.bag().num_tiles())) print(u"Bag contents are:\n{0}".format(state.bag().contents())) print(u"Rack 0 is {0}".format(state.rack(0))) print(u"Rack 1 is {0}".format(state.rack(1))) # Set player names for ix in range(2): state.set_player_name(ix, "Player " + ("A", "B")[ix]) # print(state.__str__()) # This works in Python 2 and 3 state.player_rack().set_tiles(u"stuðinn") test_move(state, u"H4 stuði") state.player_rack().set_tiles(u"dettsfj") test_move(state, u"5E detts") test_exchange(state, 3) state.player_rack().set_tiles(u"dýsturi") test_move(state, u"I3 dýs") state.player_rack().set_tiles(u"?xalmen") test_move( state, u"6E ?óx" ) # The question mark indicates a blank tile for the subsequent cover state.player_rack().set_tiles(u"eiðarps") test_move(state, u"9F eipar") test_challenge(state) test_response(state) state.player_rack().set_tiles(u"sóbetis") test_move(state, u"J3 ós") test_move(state, u"9F eiðar") test_challenge(state) test_response(state) # Tally the tiles left and calculate the final score state.finalize_score() p0, p1 = state.scores() print(u"Manual game over, final score {3} {0} : {4} {1} after {2} moves". format(p0, p1, state.num_moves(), state.player_name(0), state.player_name(1)))
class Game: """ A wrapper class for a particular game that is in process or completed. Contains inter alia a State instance. """ # The human-readable name of the computer player AUTOPLAYER_NAME = u"Netskrafl" def __init__(self, uuid = None): # Unique id of the game self.uuid = uuid # The nickname of the human (local) player self.username = None # The current game state self.state = None # Is the human player 0 or 1, where player 0 begins the game? self.player_index = 0 # The last move made by the autoplayer self.last_move = None # Was the game finished by resigning? self.resigned = False # History of moves in this game so far self.moves = [] # The current game state held in memory for different users # !!! TODO: limit the size of the cache and make it LRU _cache = dict() def _make_new(self, username): # Initialize a new, fresh game self.username = username self.state = State(drawtiles = True) self.player_index = randint(0, 1) self.state.set_player_name(self.player_index, username) self.state.set_player_name(1 - self.player_index, Game.AUTOPLAYER_NAME) @classmethod def current(cls): """ Obtain the current game state """ user = User.current() user_id = None if user is None else user.id() if not user_id: # No game state found return None if user_id in Game._cache: return Game._cache[user_id] # No game in cache: attempt to find one in the database uuid = GameModel.find_live_game(user_id) if uuid is None: # Not found in persistent storage: create a new game return cls.new(user.nickname()) # Load from persistent storage return cls.load(uuid, user.nickname()) @classmethod def new(cls, username): """ Start and initialize a new game """ game = cls(Unique.id()) # Assign a new unique id to the game game._make_new(username) # Cache the game so it can be looked up by user id user = User.current() if user is not None: Game._cache[user.id()] = game # If AutoPlayer is first to move, generate the first move if game.player_index == 1: game.autoplayer_move() # Store the new game in persistent storage game.store() return game @classmethod def load(cls, uuid, username): """ Load an already existing game from persistent storage """ gm = GameModel.fetch(uuid) if gm is None: # A game with this uuid is not found in the database: give up return None # Initialize a new Game instance with a pre-existing uuid game = cls(uuid) game.username = username game.state = State(drawtiles = False) if gm.player0 is None: # Player 0 is an Autoplayer game.player_index = 1 # Human (local) player is 1 else: assert gm.player1 is None game.player_index = 0 # Human (local) player is 0 game.state.set_player_name(game.player_index, username) game.state.set_player_name(1 - game.player_index, u"Netskrafl") # Load the current racks game.state._racks[0].set_tiles(gm.rack0) game.state._racks[1].set_tiles(gm.rack1) # Process the moves player = 0 for mm in gm.moves: m = None if mm.coord: # Normal tile move # Decode the coordinate: A15 = horizontal, 15A = vertical if mm.coord[0] in Board.ROWIDS: row = Board.ROWIDS.index(mm.coord[0]) col = int(mm.coord[1:]) - 1 horiz = True else: row = Board.ROWIDS.index(mm.coord[-1]) col = int(mm.coord[0:-1]) - 1 horiz = False # The tiles string may contain wildcards followed by their meaning # Remove the ? marks to get the "plain" word formed m = Move(mm.tiles.replace(u'?', u''), row, col, horiz) m.make_covers(game.state.board(), mm.tiles) elif mm.tiles[0:4] == u"EXCH": # Exchange move m = ExchangeMove(mm.tiles[5:]) elif mm.tiles == u"PASS": # Pass move m = PassMove() elif mm.tiles == u"RSGN": # Game resigned m = ResignMove(- mm.score) assert m is not None if m: # Do a "shallow apply" of the move, which updates # the board and internal state variables but does # not modify the bag or the racks game.state.apply_move(m, True) # Append to the move history game.moves.append((player, m)) player = 1 - player # If the moves were correctly applied, the scores should match assert game.state._scores[0] == gm.score0 assert game.state._scores[1] == gm.score1 # Find out what tiles are now in the bag game.state.recalc_bag() # Cache the game so it can be looked up by user id user = User.current() if user is not None: Game._cache[user.id()] = game return game def store(self): """ Store the game state in persistent storage """ assert self.uuid is not None user = User.current() if user is None: # No current user: can't store game assert False return gm = GameModel(id = self.uuid) gm.set_player(self.player_index, user.id()) gm.set_player(1 - self.player_index, None) gm.rack0 = self.state._racks[0].contents() gm.rack1 = self.state._racks[1].contents() gm.score0 = self.state.scores()[0] gm.score1 = self.state.scores()[1] gm.to_move = len(self.moves) % 2 gm.over = self.state.is_game_over() movelist = [] for player, m in self.moves: mm = MoveModel() coord, tiles, score = m.summary(self.state.board()) mm.coord = coord mm.tiles = tiles mm.score = score movelist.append(mm) gm.moves = movelist gm.put() def set_human_name(self, nickname): """ Set the nickname of the human player """ self.state.set_player_name(self.player_index, nickname) def resign(self): """ The human player is resigning the game """ self.resigned = True def autoplayer_move(self): """ Let the AutoPlayer make its move """ # !!! DEBUG for testing various move types # rnd = randint(0,3) # if rnd == 0: # print(u"Generating ExchangeMove") # move = ExchangeMove(self.state.player_rack().contents()[0:randint(1,7)]) # else: apl = AutoPlayer(self.state) move = apl.generate_move() self.state.apply_move(move) self.moves.append((1 - self.player_index, move)) self.last_move = move def human_move(self, move): """ Register the human move, update the score and move list """ self.state.apply_move(move) self.moves.append((self.player_index, move)) self.last_move = None # No autoplayer move yet def enum_tiles(self): """ Enumerate all tiles on the board in a convenient form """ for x, y, tile, letter in self.state.board().enum_tiles(): yield (Board.ROWIDS[x] + str(y + 1), tile, letter, 0 if tile == u'?' else Alphabet.scores[tile]) BAG_SORT_ORDER = Alphabet.order + u'?' def display_bag(self): """ Returns the bag as it should be displayed, i.e. including the autoplayer's rack """ displaybag = self.state.display_bag(1 - self.player_index) return u''.join(sorted(displaybag, key=lambda ch: Game.BAG_SORT_ORDER.index(ch))) def num_moves(self): """ Returns the number of moves in the game so far """ return len(self.moves) def client_state(self): """ Create a package of information for the client about the current state """ reply = dict() if self.state.is_game_over(): # The game is now over - one of the players finished it reply["result"] = Error.GAME_OVER # Not really an error num_moves = 1 if self.last_move is not None: # Show the autoplayer move if it was the last move in the game reply["lastmove"] = self.last_move.details() num_moves = 2 # One new move to be added to move list newmoves = [(player, m.summary(self.state.board())) for player, m in self.moves[-num_moves:]] # Lastplayer is the player who finished the game lastplayer = self.moves[-1][0] if not self.resigned: # If the game did not end by resignation, # account for the losing rack rack = self.state._racks[1 - lastplayer].contents() # Subtract the score of the losing rack from the losing player newmoves.append((1 - lastplayer, (u"", rack, -1 * Alphabet.score(rack)))) # Add the score of the losing rack to the winning player newmoves.append((lastplayer, (u"", rack, 1 * Alphabet.score(rack)))) # Add a synthetic "game over" move newmoves.append((1 - lastplayer, (u"", u"OVER", 0))) reply["newmoves"] = newmoves reply["bag"] = "" # Bag is now empty, by definition reply["xchg"] = False # Exchange move not allowed else: # Game is still in progress reply["result"] = 0 # Indicate no error reply["rack"] = self.state.player_rack().details() reply["lastmove"] = self.last_move.details() reply["newmoves"] = [(player, m.summary(self.state.board())) for player, m in self.moves[-2:]] reply["bag"] = self.display_bag() reply["xchg"] = self.state.is_exchange_allowed() reply["scores"] = self.state.scores() return reply
def _find_best_move(self, depth): """ Analyze the list of candidate moves and pick the best one """ # assert depth >= 0 if not self._candidates: # No moves: must exchange or pass instead return None if len(self._candidates) == 1: # Only one legal move: play it return self._candidates[0] # !!! TODO: Consider looking at exchange moves if there are # few and weak candidates # Calculate the score of each candidate scored_candidates = [(m, self._state.score(m)) for m in self._candidates] def keyfunc(x): # Sort moves first by descending score; # in case of ties prefer shorter words # !!! TODO: Insert more sophisticated logic here, # including whether triple-word-score opportunities # are being opened for the opponent, minimal use # of blank tiles, leaving a good vowel/consonant # balance on the rack, etc. return (- x[1], x[0].num_covers()) def keyfunc_firstmove(x): # Special case for first move: # Sort moves first by descending score, and in case of ties, # try to go to the upper half of the board for a more open game return (- x[1], x[0]._row) # Sort the candidate moves using the appropriate key function if self._board.is_empty(): # First move scored_candidates.sort(key=keyfunc_firstmove) else: # Subsequent moves scored_candidates.sort(key=keyfunc) # If we're not going deeper into the minimax analysis, # cut the crap and simply return the top scoring move if depth == 0: return scored_candidates[0][0] # Weigh top candidates by alpha-beta testing of potential # moves and counter-moves # !!! TODO: In endgame, if we have moves that complete the game (use all rack tiles) # we need not consider opponent countermoves NUM_TEST_RACKS = 20 # How many random test racks to try for statistical average NUM_CANDIDATES = 12 # How many top candidates do we look at with MiniMax? weighted_candidates = [] min_score = None print(u"Looking at {0} top scoring candidate moves".format(NUM_CANDIDATES)) # Look at the top scoring candidates for m, score in scored_candidates[0:NUM_CANDIDATES]: print(u"Candidate move {0} with raw score {1}".format(m, score)) # Create a game state where the candidate move has been played teststate = State(tileset = None, copy = self._state) # Copy constructor teststate.apply_move(m) countermoves = list() if teststate.is_game_over(): # This move finishes the game. The opponent then scores nothing # !!! TODO: (and in fact we get her tile score, but leave that aside here) avg_score = 0.0 countermoves.append(0) else: # Loop over NUM_TEST_RACKS random racks to find the average countermove score sum_score = 0 rackscores = dict() for _ in range(NUM_TEST_RACKS): # Make sure we test this for a random opponent rack teststate.randomize_and_sort_rack() rack = teststate.player_rack().contents() if rack in rackscores: # We have seen this rack before: fetch its score sc = rackscores[rack] else: # New rack: see how well it would score apl = AutoPlayer_MiniMax(teststate) # Go one level deeper into move generation move = apl._generate_move(depth = depth - 1) # Calculate the score of this random rack based move # but do not apply it to the teststate sc = teststate.score(move) if sc > 100: print(u"Countermove rack '{0}' generated move {1} scoring {2}".format(rack, move, sc)) # Cache the score rackscores[rack] = sc sum_score += sc countermoves.append(sc) # Calculate the average score of the countermoves to this candidate # !!! TODO: Maybe a median score is better than average? avg_score = float(sum_score) / NUM_TEST_RACKS print(u"Average score of {0} countermove racks is {1:.2f}".format(NUM_TEST_RACKS, avg_score)) print(countermoves) # Keep track of the lowest countermove score across all candidates as a baseline min_score = avg_score if (min_score is None) or (avg_score < min_score) else min_score # Keep track of the weighted candidate moves weighted_candidates.append((m, score, avg_score)) print(u"Lowest score of countermove to all evaluated candidates is {0:.2f}".format(min_score)) # Sort the candidates by the plain score after subtracting the effect of # potential countermoves, measured as the countermove score in excess of # the lowest countermove score found weighted_candidates.sort(key = lambda x: float(x[1]) - (x[2] - min_score), reverse = True) print(u"AutoPlayer_MinMax: Rack '{0}' generated {1} candidate moves:".format(self._rack, len(scored_candidates))) # Show top 20 candidates for m, sc, wsc in weighted_candidates: print(u"Move {0} score {1} weighted {2:.2f}".format(m, sc, float(sc) - (wsc - min_score))) # Return the highest-scoring candidate return weighted_candidates[0][0]
def _find_best_move(self, depth): """ Analyze the list of candidate moves and pick the best one """ # assert depth >= 0 if not self._candidates: # No moves: must exchange or pass instead return None if len(self._candidates) == 1: # Only one legal move: play it return self._candidates[0] # !!! TODO: Consider looking at exchange moves if there are # few and weak candidates # Calculate the score of each candidate scored_candidates = [(m, self._state.score(m)) for m in self._candidates] def keyfunc(x): # Sort moves first by descending score; # in case of ties prefer shorter words # !!! TODO: Insert more sophisticated logic here, # including whether triple-word-score opportunities # are being opened for the opponent, minimal use # of blank tiles, leaving a good vowel/consonant # balance on the rack, etc. return (-x[1], x[0].num_covers()) def keyfunc_firstmove(x): # Special case for first move: # Sort moves first by descending score, and in case of ties, # try to go to the upper half of the board for a more open game return (-x[1], x[0]._row) # Sort the candidate moves using the appropriate key function if self._board.is_empty(): # First move scored_candidates.sort(key=keyfunc_firstmove) else: # Subsequent moves scored_candidates.sort(key=keyfunc) # If we're not going deeper into the minimax analysis, # cut the crap and simply return the top scoring move if depth == 0: return scored_candidates[0][0] # Weigh top candidates by alpha-beta testing of potential # moves and counter-moves # !!! TODO: In endgame, if we have moves that complete the game (use all rack tiles) # we need not consider opponent countermoves NUM_TEST_RACKS = 20 # How many random test racks to try for statistical average NUM_CANDIDATES = 12 # How many top candidates do we look at with MiniMax? weighted_candidates = [] min_score = None print(u"Looking at {0} top scoring candidate moves".format( NUM_CANDIDATES)) # Look at the top scoring candidates for m, score in scored_candidates[0:NUM_CANDIDATES]: print(u"Candidate move {0} with raw score {1}".format(m, score)) # Create a game state where the candidate move has been played teststate = State(tileset=None, copy=self._state) # Copy constructor teststate.apply_move(m) countermoves = list() if teststate.is_game_over(): # This move finishes the game. The opponent then scores nothing # !!! TODO: (and in fact we get her tile score, but leave that aside here) avg_score = 0.0 countermoves.append(0) else: # Loop over NUM_TEST_RACKS random racks to find the average countermove score sum_score = 0 rackscores = dict() for _ in range(NUM_TEST_RACKS): # Make sure we test this for a random opponent rack teststate.randomize_and_sort_rack() rack = teststate.player_rack().contents() if rack in rackscores: # We have seen this rack before: fetch its score sc = rackscores[rack] else: # New rack: see how well it would score apl = AutoPlayer_MiniMax(teststate) # Go one level deeper into move generation move = apl._generate_move(depth=depth - 1) # Calculate the score of this random rack based move # but do not apply it to the teststate sc = teststate.score(move) if sc > 100: print( u"Countermove rack '{0}' generated move {1} scoring {2}" .format(rack, move, sc)) # Cache the score rackscores[rack] = sc sum_score += sc countermoves.append(sc) # Calculate the average score of the countermoves to this candidate # !!! TODO: Maybe a median score is better than average? avg_score = float(sum_score) / NUM_TEST_RACKS print(u"Average score of {0} countermove racks is {1:.2f}".format( NUM_TEST_RACKS, avg_score)) print(countermoves) # Keep track of the lowest countermove score across all candidates as a baseline min_score = avg_score if (min_score is None) or ( avg_score < min_score) else min_score # Keep track of the weighted candidate moves weighted_candidates.append((m, score, avg_score)) print( u"Lowest score of countermove to all evaluated candidates is {0:.2f}" .format(min_score)) # Sort the candidates by the plain score after subtracting the effect of # potential countermoves, measured as the countermove score in excess of # the lowest countermove score found weighted_candidates.sort(key=lambda x: float(x[1]) - (x[2] - min_score), reverse=True) print(u"AutoPlayer_MinMax: Rack '{0}' generated {1} candidate moves:". format(self._rack, len(scored_candidates))) # Show top 20 candidates for m, sc, wsc in weighted_candidates: print(u"Move {0} score {1} weighted {2:.2f}".format( m, sc, float(sc) - (wsc - min_score))) # Return the highest-scoring candidate return weighted_candidates[0][0]